loadTask(Context context, SchedulesAndTasksModel.TaskScheduleModel task);
/**
* This initial task may include profile items such as height and weight that may need to be
* processed differently than a normal task result.
*
- * This task is presented to the user when initially entering the {@link MainActivity}
- *
* @param context android context
* @param taskResult initial TaskResult object to process
*/
@@ -239,4 +356,9 @@ public static void init(DataProvider instance) {
* returning true if forgitpassword request was successful
*/
public abstract Observable forgotPassword(Context context, String email);
+
+ /**
+ * @return the Study ID for the study
+ */
+ public abstract String getStudyId();
}
diff --git a/skin/src/main/java/org/researchstack/skin/DataResponse.java b/backbone/src/main/java/org/researchstack/backbone/DataResponse.java
similarity index 93%
rename from skin/src/main/java/org/researchstack/skin/DataResponse.java
rename to backbone/src/main/java/org/researchstack/backbone/DataResponse.java
index 05efef2d4..617bc1695 100644
--- a/skin/src/main/java/org/researchstack/skin/DataResponse.java
+++ b/backbone/src/main/java/org/researchstack/backbone/DataResponse.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin;
+package org.researchstack.backbone;
public class DataResponse {
private String message;
diff --git a/skin/src/main/java/org/researchstack/skin/PermissionRequestManager.java b/backbone/src/main/java/org/researchstack/backbone/PermissionRequestManager.java
similarity index 97%
rename from skin/src/main/java/org/researchstack/skin/PermissionRequestManager.java
rename to backbone/src/main/java/org/researchstack/backbone/PermissionRequestManager.java
index 23c748e4e..d3a74d5a2 100644
--- a/skin/src/main/java/org/researchstack/skin/PermissionRequestManager.java
+++ b/backbone/src/main/java/org/researchstack/backbone/PermissionRequestManager.java
@@ -1,15 +1,15 @@
-package org.researchstack.skin;
+package org.researchstack.backbone;
import android.Manifest;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
-import android.support.annotation.DrawableRes;
-import android.support.annotation.StringRes;
-import android.support.v4.content.ContextCompat;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.StringRes;
+import androidx.core.content.ContextCompat;
-import org.researchstack.skin.ui.layout.PermissionStepLayout;
+import org.researchstack.backbone.ui.step.layout.PermissionStepLayout;
import java.util.ArrayList;
import java.util.Collection;
diff --git a/skin/src/main/java/org/researchstack/skin/ResearchStack.java b/backbone/src/main/java/org/researchstack/backbone/ResearchStack.java
similarity index 86%
rename from skin/src/main/java/org/researchstack/skin/ResearchStack.java
rename to backbone/src/main/java/org/researchstack/backbone/ResearchStack.java
index 84b3500ea..da18998bb 100644
--- a/skin/src/main/java/org/researchstack/skin/ResearchStack.java
+++ b/backbone/src/main/java/org/researchstack/backbone/ResearchStack.java
@@ -1,14 +1,14 @@
-package org.researchstack.skin;
+package org.researchstack.backbone;
import android.app.Application;
import android.content.Context;
-import org.researchstack.backbone.StorageAccess;
import org.researchstack.backbone.storage.database.AppDatabase;
import org.researchstack.backbone.storage.file.EncryptionProvider;
import org.researchstack.backbone.storage.file.FileAccess;
import org.researchstack.backbone.storage.file.PinCodeConfig;
-import org.researchstack.skin.notification.NotificationConfig;
+import org.researchstack.backbone.notification.NotificationConfig;
+import org.researchstack.backbone.onboarding.OnboardingManager;
/**
* Research stack is a singleton which controls all the major components of the ResearchStack
@@ -47,6 +47,8 @@ public synchronized static ResearchStack getInstance() {
public static void init(Context context, ResearchStack concreteResearchStack) {
instance = concreteResearchStack;
+ AppPrefs.init(context);
+
ResourceManager.init(concreteResearchStack.createResourceManagerImplementation(context));
UiManager.init(concreteResearchStack.createUiManagerImplementation(context));
@@ -65,6 +67,10 @@ public static void init(Context context, ResearchStack concreteResearchStack) {
PermissionRequestManager.init(concreteResearchStack.createPermissionRequestManagerImplementation(
context));
+
+ // OnboardingManager is not a singleton, so that in the future,
+ // there can potentially be multiple onboarding flows depending
+ instance.createOnboardingManager(context);
}
/**
@@ -91,6 +97,17 @@ public static void init(Context context, ResearchStack concreteResearchStack) {
*/
protected abstract EncryptionProvider getEncryptionProvider(Context context);
+ /**
+ * @return concrete implementation of {@link OnboardingManager}
+ */
+ public abstract OnboardingManager getOnboardingManager();
+
+ /**
+ * Called within {@link #init(Context, ResearchStack)} to initialize {@link OnboardingManager} implementation
+ * @param context can be activity or application context, only used for resources
+ */
+ public abstract void createOnboardingManager(Context context);
+
/**
* Called within {@link #init(Context, ResearchStack)} to initialize {@link FileAccess} implementation
*
@@ -128,6 +145,8 @@ public static void init(Context context, ResearchStack concreteResearchStack) {
*
* @param context android Contenxt
* @return concrete implementation of {@link TaskProvider}
+ *
+ * @deprecated use org.researchstack.backbone.onboarding.OnboardingManager instead
*/
protected abstract TaskProvider createTaskProviderImplementation(Context context);
@@ -146,5 +165,4 @@ public static void init(Context context, ResearchStack concreteResearchStack) {
* @return concrete implementation of {@link PermissionRequestManager}
*/
protected abstract PermissionRequestManager createPermissionRequestManagerImplementation(Context context);
-
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/ResourceManager.java b/backbone/src/main/java/org/researchstack/backbone/ResourceManager.java
new file mode 100644
index 000000000..d18b63843
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/ResourceManager.java
@@ -0,0 +1,196 @@
+package org.researchstack.backbone;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * This class is responsible for returning paths of resources defined in the assets folder. This
+ * class has more structure and expects certain assets to be defined for use within the framework.
+ */
+public abstract class ResourceManager extends ResourcePathManager {
+
+ /**
+ * The resource map is a way to inject your own resources into the ResourceManager
+ */
+ private Map resourceMap = new HashMap<>();
+
+ /**
+ * Returns a singleton static instance of the ResourceManager class
+ *
+ * @return A singleton static instance of the ResourceManager class
+ */
+ public static ResourceManager getInstance() {
+ return (ResourceManager) ResourcePathManager.getInstance();
+ }
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * StudyOverview file.
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * StudyOverview file.
+ */
+ public abstract Resource getStudyOverview();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing an
+ * HTML version of the Consent file.
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing an
+ * HTML version of the Consent file.
+ */
+ public abstract Resource getConsentHtml();
+
+ /**
+ * This is currently unused but will be when share-pdf feature is implemented
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing an PDF
+ * version of the Consent file.
+ */
+ public abstract Resource getConsentPDF();
+
+ /**
+ * The consent section differs from ResearchKit™ as ResearchStack includes extra
+ * documentProperties along with support for quiz steps
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing a
+ * consent section file
+ */
+ public abstract Resource getConsentSections();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing a
+ * learn-sections file
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing a
+ * learn-sections file
+ */
+ public abstract Resource getLearnSections();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * privacy policy
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * privacy policy
+ */
+ public abstract Resource getPrivacyPolicy();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * software notices
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * software notices
+ */
+ public abstract Resource getSoftwareNotices();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * tasks and schedules file
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * tasks and schedules file
+ */
+ public abstract Resource getTasksAndSchedules();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing a
+ * individual task file
+ *
+ * @param taskFileName the filename for th task
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing a
+ * individual task file
+ */
+ public abstract Resource getTask(String taskFileName);
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * inclusion criteria
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * inclusion criteria
+ */
+ public abstract Resource getInclusionCriteria();
+
+ /**
+ * Returns a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * Onboarding sections
+ *
+ * @return a {@link org.researchstack.backbone.ResourcePathManager.Resource} representing the
+ * Onboarding sections
+ */
+ public abstract Resource getOnboardingManager();
+
+ /**
+ * @param resourceName the name of the resource to find
+ * @return a Resource this manager provides, null if none exists
+ */
+ public Resource getResource(String resourceName) {
+ Resource resource = resourceMap.get(resourceName);
+ // If we use the abstract methods (backwards compatibility)
+ // the resource may exist in one of the methods
+ if (resource == null) {
+ List resourceList = reflectToCreateResourceList();
+ for (Resource resourceInList : resourceList) {
+ if (resourceInList.getName().equals(resourceName)) {
+ return resourceInList;
+ }
+ }
+ }
+ return resource;
+ }
+
+ /**
+ * Uses reflection to find all methods that return "Resource" types and builds it into a list
+ * @return a list of Resource objects
+ */
+ private List reflectToCreateResourceList() {
+ List resourceList = new ArrayList<>();
+ // Look at all methods of ResourceManager to find ones that return type "Resource"
+ Method[] resourceMethods = ResourceManager.class.getDeclaredMethods();
+ for (Method method : resourceMethods) {
+ if (method.getReturnType().equals(ResourcePathManager.Resource.class)) {
+ String errorMessage = null;
+ try {
+ Object resourceObj = method.invoke(ResourceManager.getInstance());
+ if (resourceObj instanceof ResourcePathManager.Resource) {
+ resourceList.add((ResourcePathManager.Resource)resourceObj);
+ }
+ } catch (Exception e) {
+ errorMessage = e.getMessage();
+ }
+ if (errorMessage != null) {
+ throw new IllegalStateException("You must define a method in ResourceManager " +
+ "that returns a Resource for the resourceName or add it to the" +
+ "ResourceManager with addResource()");
+ }
+ }
+ }
+ return resourceList;
+ }
+
+ /**
+ * @param resourceName the name of the resource that will be
+ * @param resource the full path, type, and name of the resource
+ * @return an instance of this class to chain adding resources
+ */
+ public ResourceManager addResource(String resourceName, Resource resource) {
+ resourceMap.put(resourceName, resource);
+ return this;
+ }
+
+ /**
+ * @return a duplicate copy of the ResourceManager's custom resource map
+ */
+ public Map getResourceMap() {
+ return new HashMap<>(resourceMap);
+ }
+
+ public void removeResource(String name) {
+ resourceMap.remove(name);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/ResourcePathManager.java b/backbone/src/main/java/org/researchstack/backbone/ResourcePathManager.java
index 3943deef6..e54935b5b 100644
--- a/backbone/src/main/java/org/researchstack/backbone/ResourcePathManager.java
+++ b/backbone/src/main/java/org/researchstack/backbone/ResourcePathManager.java
@@ -28,6 +28,8 @@
* ResearchKit™ applications
*/
public abstract class ResourcePathManager {
+ // TODO: if we are going to go with a singleton instance of Gson,
+ // TODO: we need a simple way to inject type adapters into it - MDP 1/13/2017
private static Gson gson = new GsonBuilder().setDateFormat("MMM yyyy").create();
private static ResourcePathManager instance;
@@ -64,7 +66,11 @@ public static ResourcePathManager getInstance() {
* @return String representation of the file
*/
public static String getResourceAsString(Context context, String filePath) {
- return new String(getResourceAsBytes(context, filePath), Charset.forName("UTF-8"));
+ byte[] fileBytes = getResourceAsBytes(context, filePath);
+ if (fileBytes == null) {
+ return null;
+ }
+ return new String(fileBytes, Charset.forName("UTF-8"));
}
/**
@@ -76,6 +82,9 @@ public static String getResourceAsString(Context context, String filePath) {
*/
public static byte[] getResourceAsBytes(Context context, String filePath) {
InputStream is = getResouceAsInputStream(context, filePath);
+ if (is == null) {
+ return null;
+ }
ByteArrayOutputStream byteOutput = new ByteArrayOutputStream();
byte[] readBuffer = new byte[4 * 1024];
@@ -112,6 +121,9 @@ public static byte[] getResourceAsBytes(Context context, String filePath) {
* @return InputStream representation of the asset
*/
public static InputStream getResouceAsInputStream(Context context, String filePath) {
+ if (context == null) {
+ return null;
+ }
AssetManager assetManager = context.getAssets();
InputStream inputStream = null;
try {
@@ -124,6 +136,7 @@ public static InputStream getResouceAsInputStream(Context context, String filePa
/**
* Load resource from a file-path and turns contents to a objects, of type T, for consumption
*
+ * @param object type
* @param context android context
* @param clazz the class of T
* @param filePath relative file path
@@ -258,6 +271,7 @@ public int getType() {
* Create this Resource into an Object of type T. This method will only work for Json
* files.
*
+ * @param object type
* @param context android context
* @return object of type T
*/
diff --git a/backbone/src/main/java/org/researchstack/backbone/StorageAccess.java b/backbone/src/main/java/org/researchstack/backbone/StorageAccess.java
index a810e15c0..024b777c9 100644
--- a/backbone/src/main/java/org/researchstack/backbone/StorageAccess.java
+++ b/backbone/src/main/java/org/researchstack/backbone/StorageAccess.java
@@ -2,9 +2,10 @@
import android.app.Application;
import android.content.Context;
+import android.content.SharedPreferences;
import android.os.Handler;
import android.os.Looper;
-import android.support.annotation.MainThread;
+import androidx.annotation.MainThread;
import org.researchstack.backbone.storage.database.AppDatabase;
import org.researchstack.backbone.storage.file.EncryptionProvider;
@@ -40,6 +41,9 @@ public class StorageAccess {
private static final boolean CHECK_THREADS = false;
+ private static final String SHARED_PREFS_KEY = "StorageAccessSharedPrefsKey";
+ private static final String USES_FINGERPRINT_KEY = "UsesFingerprintKey";
+
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
// Static Fields
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
@@ -126,7 +130,6 @@ public boolean hasPinCode(Context context) {
return encryptionProvider.hasPinCode(context);
}
-
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
// Storage Access request and notification
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
@@ -161,7 +164,7 @@ public void requestStorageAccess(Context context) {
* Registers a listener. If you want to read/write data, you'll need to implement this to know
* when file access is ready.
*
- * @param storageAccessListener
+ * @param storageAccessListener the listener to register
*/
@MainThread
public final void register(StorageAccessListener storageAccessListener) {
@@ -179,7 +182,7 @@ public final void register(StorageAccessListener storageAccessListener) {
* Guess what this does. Yes, you'll need to call it if you called register or you'll have
* memory leaks and possibly crashes on callbacks to dead clients.
*
- * @param storageAccessListener
+ * @param storageAccessListener the registered listener
*/
@MainThread
public final void unregister(StorageAccessListener storageAccessListener) {
@@ -256,7 +259,7 @@ public void logAccessTime() {
*
* @param context android context
* @param pin string of the pin to attempt authentication
- * @throws StorageAccessException
+ * @throws StorageAccessException if the authentication failed
*/
public void authenticate(Context context, String pin) {
encryptionProvider.startWithPassphrase(context, pin);
@@ -287,20 +290,62 @@ public void createPinCode(Context context, String pin) {
* @param context android context
* @param oldPin the old pin
* @param newPin the new pin, which should already be validated (enter + confirm)
- * @throws StorageAccessException
+ * @throws StorageAccessException if the pin code change failed
*/
public void changePinCode(Context context, String oldPin, String newPin) {
encryptionProvider.changePinCode(context, oldPin, newPin);
injectEncrypter();
}
+ /**
+ * @param context can be android or application
+ * @return true if pin code is backed by fingerprint, false if it is user created
+ */
+ public boolean usesFingerprint(Context context) {
+ SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_KEY, Context.MODE_PRIVATE);
+ return prefs.getString(USES_FINGERPRINT_KEY, null) != null;
+ }
+
+ /**
+ * @param context can be android or application
+ * @return the fingerprint data encrypted
+ */
+ public String getFingerprint(Context context) {
+ SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_KEY, Context.MODE_PRIVATE);
+ return prefs.getString(USES_FINGERPRINT_KEY, null);
+ }
+
+ /**
+ * Method must be called and set to true if the user registers their fingerprint
+ * @param context can be android or application
+ * @param encryptedKey the key used for storage, MUST BE ENCRYPTED and the encryption key for it backed by keystore
+ */
+ public void setUsesFingerprint(Context context, String encryptedKey) {
+ SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_KEY, Context.MODE_PRIVATE);
+ prefs.edit().putString(USES_FINGERPRINT_KEY, encryptedKey).apply();
+ }
+
+ /**
+ * Removes any history of using fingerprint for authentication
+ * @param context can be android or application
+ */
+ protected void removeSharedPreference(Context context) {
+ SharedPreferences prefs = context.getSharedPreferences(SHARED_PREFS_KEY, Context.MODE_PRIVATE);
+ if (prefs != null) {
+ prefs.edit().clear().apply();
+ }
+ }
+
/**
* Removes the pin code if one exists.
*
* @param context android context
*/
public void removePinCode(Context context) {
- encryptionProvider.removePinCode(context);
+ if (encryptionProvider != null) {
+ encryptionProvider.removePinCode(context);
+ }
+ removeSharedPreference(context);
}
private void injectEncrypter() {
diff --git a/skin/src/main/java/org/researchstack/skin/TaskProvider.java b/backbone/src/main/java/org/researchstack/backbone/TaskProvider.java
similarity index 88%
rename from skin/src/main/java/org/researchstack/skin/TaskProvider.java
rename to backbone/src/main/java/org/researchstack/backbone/TaskProvider.java
index b3b9658ff..8d629522b 100644
--- a/skin/src/main/java/org/researchstack/skin/TaskProvider.java
+++ b/backbone/src/main/java/org/researchstack/backbone/TaskProvider.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin;
+package org.researchstack.backbone;
import android.app.Application;
@@ -7,6 +7,7 @@
/**
* TaskProvider is used as a way for the Framework to get Tasks needed throughout the onboarding
* process. This allows you to implement your own Tasks if needed.
+ * @deprecated use org.researchstack.backbone.onboarding.OnboardingManager instead
*/
public abstract class TaskProvider {
/**
@@ -37,6 +38,7 @@ public abstract class TaskProvider {
* Application#onCreate()} method.
*
* @param manager an implementation of ResourcePathManager
+ * @deprecated use org.researchstack.backbone.onboarding.OnboardingManager instead
*/
public static void init(TaskProvider manager) {
TaskProvider.instance = manager;
@@ -57,10 +59,10 @@ public static TaskProvider getInstance() {
}
/**
- * Used, in combination of {@link #put(String, Task)}, for task lookup and resuse
+ * Used, in combination of {@link #put(String, Task)}, for task lookup and reuse
*
* @param taskId the task id
- * @return a task object with an id of {@param taskId}
+ * @return a task object with the provided id
*/
public abstract Task get(String taskId);
diff --git a/skin/src/main/java/org/researchstack/skin/UiManager.java b/backbone/src/main/java/org/researchstack/backbone/UiManager.java
similarity index 64%
rename from skin/src/main/java/org/researchstack/skin/UiManager.java
rename to backbone/src/main/java/org/researchstack/backbone/UiManager.java
index c3849d8ff..c9b0f89d7 100644
--- a/skin/src/main/java/org/researchstack/skin/UiManager.java
+++ b/backbone/src/main/java/org/researchstack/backbone/UiManager.java
@@ -1,11 +1,15 @@
-package org.researchstack.skin;
+package org.researchstack.backbone;
import android.app.Application;
import android.content.Context;
+import androidx.fragment.app.Fragment;
+
import org.researchstack.backbone.result.StepResult;
import org.researchstack.backbone.step.Step;
-import org.researchstack.skin.notification.TaskNotificationReceiver;
+import org.researchstack.backbone.utils.TextUtils;
+import org.researchstack.backbone.notification.TaskNotificationReceiver;
+import org.researchstack.backbone.ui.fragment.ShareFragment;
import java.util.List;
@@ -43,7 +47,7 @@ public static UiManager getInstance() {
/**
* All ActionItems returned by this method should define a title, icon, and class. These
* ActionItems are used to populate an ActionBar in the main Activity, so the class should be
- * that of activity (either SettingsActivity & LearnActivity or your own items)
+ * that of activity (either SettingsActivity & LearnActivity or your own items)
*
* @return a list of ActionItems for display in the MainActivity ActionBar
*/
@@ -52,31 +56,39 @@ public static UiManager getInstance() {
/**
* All ActionItems returned by this method should define a title, icon, and class. These items
* are used to fill a pager in the MainActivity. The framework uses the class objects from this
- * list to create a Fragments for tha pager. It is imperative that the defined classes be of
- * instance {@link android.support.v4.app.Fragment}.
+ * list to create Fragments for tha pager. It is imperative that the defined classes be of
+ * instance {@link Fragment}.
*
* @return a list of ActionItems for display in the MainActivity ActionBar
*/
public abstract List getMainTabBarItems();
/**
- * Includsion Criteria Step is one of the first Steps the user will come in contact with. It is
+ * Inclusion Criteria Step is one of the first Steps the user will come in contact with. It is
* a question / form of questions whos result is used to see if the user elligible or
- * inelligible for the study. That result in calculate and returned within {@link
- * #isInclusionCriteriaValid(StepResult)}
+ * inelligible for the study.
+ *
+ * This method is now deprecated and Inclusion Criteria will now be loaded from a JSON file as
+ * defined {@link ResourceManager#getInclusionCriteria()}.
*
* @param context android context
* @return a Step used for Eligibility within the onboarding process
*/
+ @Deprecated
public abstract Step getInclusionCriteriaStep(Context context);
/**
* Method used by the framework to show if the user the result of the {@link
* #getInclusionCriteriaStep(Context)}.
*
+ * This method is now deprecated and Inclusion Criteria will now be loaded from a JSON file as
+ * defined {@link ResourceManager#getInclusionCriteria()}. The JSON file
+ * contains expected answers which will be used to determine if the inclusion criteria is valid.
+ *
* @param result StepResult object that contains the answers of the InclusionCriteria step
- * @return true if the user is elligible for the study
+ * @return true if the user is elligible for the study
*/
+ @Deprecated
public abstract boolean isInclusionCriteriaValid(StepResult result);
/**
@@ -85,12 +97,22 @@ public static UiManager getInstance() {
* All data will still be collected and uploaded when the user successfully signs up for the
* first time Defaults to false.
*
+ *
* @return true if consent is skippable
*/
public boolean isConsentSkippable() {
return false;
}
+ /**
+ * Returns true if the password supplied by the user in the sign up step is valid.
+ * @param password the password to validate
+ * @return true if the password is valid, false otherwise
+ */
+ public boolean isValidPassword(String password) {
+ return ! TextUtils.isEmpty(password);
+ }
+
/**
* Returns the BroadCastReceiver class responsible for consuming alarms for triggering
* Notifications
@@ -100,4 +122,15 @@ public boolean isConsentSkippable() {
public Class> getTaskNotificationReceiver() {
return TaskNotificationReceiver.class;
}
+
+ /**
+ * Return the ShareFragment to be used. Individual apps can extend and modify the fragment if they
+ * desire custom logic or presentation.
+ *
+ * @return The ShareFragment to use.
+ */
+ public ShareFragment getShareFragment()
+ {
+ return new ShareFragment();
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/AnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/AnswerFormat.java
index d489242bd..a09e8e5ee 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/AnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/AnswerFormat.java
@@ -4,10 +4,12 @@
import org.researchstack.backbone.ui.step.body.DecimalQuestionBody;
import org.researchstack.backbone.ui.step.body.DurationQuestionBody;
import org.researchstack.backbone.ui.step.body.FormBody;
+import org.researchstack.backbone.ui.step.body.ImageChoiceBody;
import org.researchstack.backbone.ui.step.body.IntegerQuestionBody;
import org.researchstack.backbone.ui.step.body.MultiChoiceQuestionBody;
import org.researchstack.backbone.ui.step.body.NotImplementedStepBody;
import org.researchstack.backbone.ui.step.body.SingleChoiceQuestionBody;
+import org.researchstack.backbone.ui.step.body.StepBody;
import org.researchstack.backbone.ui.step.body.TextQuestionBody;
import java.io.Serializable;
@@ -23,10 +25,7 @@
* a {@link org.researchstack.backbone.ui.ViewTaskActivity}.
*/
public abstract class AnswerFormat implements Serializable {
- /**
- * Default constructor. The appropriate subclass of AnswerFormat should be used instead of this
- * directly.
- */
+ /* Default constructor needed for serialization/deserialization of object */
public AnswerFormat() {
}
@@ -62,16 +61,17 @@ public enum Type implements QuestionType {
TimeInterval(NotImplementedStepBody.class),
Duration(DurationQuestionBody.class),
Location(NotImplementedStepBody.class),
- Form(FormBody.class);
+ Form(FormBody.class),
+ ImageChoice(ImageChoiceBody.class);
- private Class> stepBodyClass;
+ private Class extends StepBody> stepBodyClass;
- Type(Class> stepBodyClass) {
+ Type(Class extends StepBody> stepBodyClass) {
this.stepBodyClass = stepBodyClass;
}
@Override
- public Class> getStepBodyClass() {
+ public Class extends StepBody> getStepBodyClass() {
return stepBodyClass;
}
@@ -108,6 +108,6 @@ public enum DateAnswerStyle {
* org.researchstack.backbone.ui.step.body.StepBody} class.
*/
public interface QuestionType {
- Class> getStepBodyClass();
+ Class extends StepBody> getStepBodyClass();
}
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/BirthDateAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/BirthDateAnswerFormat.java
index a7c3a55f0..9950449ab 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/BirthDateAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/BirthDateAnswerFormat.java
@@ -1,48 +1,63 @@
package org.researchstack.backbone.answerformat;
-
import org.researchstack.backbone.R;
import org.researchstack.backbone.ui.step.body.BodyAnswer;
import java.util.Calendar;
import java.util.Date;
-public class BirthDateAnswerFormat extends DateAnswerFormat {
+public class BirthDateAnswerFormat extends DateAnswerFormat
+{
private final int minAge;
private final int maxAge;
- public BirthDateAnswerFormat(Date defaultDate, int minAge, int maxAge) {
- super(DateAnswerStyle.Date, defaultDate, dateFromAge(maxAge), dateFromAge(minAge));
- this.minAge = minAge;
- this.maxAge = maxAge;
+ /* Default constructor needed for serilization/deserialization of object */
+ BirthDateAnswerFormat()
+ {
+ super();
+ minAge = 0;
+ maxAge = Integer.MAX_VALUE;
}
- private static Date dateFromAge(int age) {
+ private static Date dateFromAge(int age)
+ {
Calendar calendar = Calendar.getInstance();
- if (age != 0) {
- calendar.add(Calendar.YEAR, -age);
+ if(age != 0)
+ {
+ calendar.add(Calendar.YEAR, - age);
return calendar.getTime();
}
return null;
}
+ public BirthDateAnswerFormat(Date defaultDate, int minAge, int maxAge)
+ {
+ super(DateAnswerStyle.Date, defaultDate, dateFromAge(maxAge), dateFromAge(minAge));
+ this.minAge = minAge;
+ this.maxAge = maxAge;
+ }
+
@Override
- public BodyAnswer validateAnswer(Date resultDate) {
+ public BodyAnswer validateAnswer(Date resultDate)
+ {
Date minDate = getMinimumDate();
Date maxDate = getMaximumDate();
- if (minDate != null && isOnOrBefore(resultDate, minDate)) {
+ if(minDate != null && isOnOrBefore(resultDate, minDate))
+ {
return new BodyAnswer(false, R.string.rsb_birth_date_too_old, String.valueOf(maxAge));
}
- if (maxDate != null && !isOnOrBefore(resultDate, maxDate)) {
+ if(maxDate != null && ! isOnOrBefore(resultDate, maxDate))
+ {
return new BodyAnswer(false, R.string.rsb_birth_date_too_young, String.valueOf(minAge));
}
return BodyAnswer.VALID;
}
- private boolean isOnOrBefore(Date inputDate, Date cutoffDate) {
+ private boolean isOnOrBefore(Date inputDate, Date cutoffDate)
+ {
Calendar calendar = Calendar.getInstance();
calendar.setTime(inputDate);
int year = calendar.get(Calendar.YEAR);
@@ -53,4 +68,4 @@ private boolean isOnOrBefore(Date inputDate, Date cutoffDate) {
return year < cutoffYear || (year == cutoffYear && dayOfYear <= cutoffDayOfYear);
}
-}
+}
\ No newline at end of file
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/BooleanAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/BooleanAnswerFormat.java
index d4d40eae9..34c653fd5 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/BooleanAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/BooleanAnswerFormat.java
@@ -12,6 +12,20 @@
*/
public class BooleanAnswerFormat extends ChoiceAnswerFormat {
+ public void setTextValues(String trueString, String falseString) {
+ setAnswerStyle(ChoiceAnswerStyle.SingleChoice);
+ setChoices(new Choice[] {
+ new Choice<>(trueString, true),
+ new Choice<>(falseString, false)
+ });
+ }
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public BooleanAnswerFormat()
+ {
+ super();
+ }
+
/**
* Constructs a single choice question with true/false values, using the specified strings to
* represent those choices to the user.
@@ -20,9 +34,8 @@ public class BooleanAnswerFormat extends ChoiceAnswerFormat {
* @param falseString a string representing false ("No", "False", etc)
*/
public BooleanAnswerFormat(String trueString, String falseString) {
- super(ChoiceAnswerStyle.SingleChoice,
- new Choice<>(trueString, true),
- new Choice<>(falseString, false));
+ this();
+ setTextValues(trueString, falseString);
}
@Override
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/ChoiceAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/ChoiceAnswerFormat.java
index d87ae7296..de45af267 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/ChoiceAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/ChoiceAnswerFormat.java
@@ -9,7 +9,22 @@
*/
public class ChoiceAnswerFormat extends AnswerFormat {
private AnswerFormat.ChoiceAnswerStyle answerStyle;
+ public AnswerFormat.ChoiceAnswerStyle getAnswerStyle() {
+ return answerStyle;
+ }
+ public void setAnswerStyle(AnswerFormat.ChoiceAnswerStyle style) {
+ answerStyle = style;
+ }
private Choice[] choices;
+ public void setChoices(Choice[] choices) {
+ this.choices = choices;
+ }
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public ChoiceAnswerFormat()
+ {
+ super();
+ }
/**
* Creates an answer format with the specified answerStyle(single or multichoice) and collection
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/DateAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/DateAnswerFormat.java
index f4c06047c..ac636db4f 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/DateAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/DateAnswerFormat.java
@@ -1,5 +1,4 @@
package org.researchstack.backbone.answerformat;
-
import org.researchstack.backbone.R;
import org.researchstack.backbone.ui.step.body.BodyAnswer;
import org.researchstack.backbone.utils.FormatHelper;
@@ -10,7 +9,8 @@
* The DateAnswerFormat class represents the answer format for questions that require users to enter
* a date, or a date and time.
*/
-public class DateAnswerFormat extends AnswerFormat {
+public class DateAnswerFormat extends AnswerFormat
+{
private DateAnswerStyle style;
@@ -20,11 +20,19 @@ public class DateAnswerFormat extends AnswerFormat {
private Date maximumDate;
- public DateAnswerFormat(DateAnswerStyle style) {
+ /* Default constructor needed for serilization/deserialization of object */
+ DateAnswerFormat()
+ {
+ super();
+ }
+
+ public DateAnswerFormat(DateAnswerStyle style)
+ {
this.style = style;
}
- public DateAnswerFormat(DateAnswerStyle style, Date defaultDate, Date minimumDate, Date maximumDate) {
+ public DateAnswerFormat(DateAnswerStyle style, Date defaultDate, Date minimumDate, Date maximumDate)
+ {
this.style = style;
this.defaultDate = defaultDate;
this.minimumDate = minimumDate;
@@ -36,7 +44,8 @@ public DateAnswerFormat(DateAnswerStyle style, Date defaultDate, Date minimumDat
*
* @return the style of the date entry
*/
- public DateAnswerStyle getStyle() {
+ public DateAnswerStyle getStyle()
+ {
return style;
}
@@ -48,7 +57,8 @@ public DateAnswerStyle getStyle() {
*
* @return the default date for the date picker presented to the user, or null
*/
- public Date getDefaultDate() {
+ public Date getDefaultDate()
+ {
return defaultDate;
}
@@ -59,7 +69,8 @@ public Date getDefaultDate() {
*
* @return returns the minimum allowed date, or null
*/
- public Date getMinimumDate() {
+ public Date getMinimumDate()
+ {
return minimumDate;
}
@@ -70,33 +81,38 @@ public Date getMinimumDate() {
*
* @return returns the maximum allowed date, or null
*/
- public Date getMaximumDate() {
+ public Date getMaximumDate()
+ {
return maximumDate;
}
@Override
- public QuestionType getQuestionType() {
- if (style == DateAnswerStyle.Date) return Type.Date;
- if (style == DateAnswerStyle.DateAndTime) return Type.DateAndTime;
- if (style == DateAnswerStyle.TimeOfDay) return Type.TimeOfDay;
+ public QuestionType getQuestionType()
+ {
+ if(style == DateAnswerStyle.Date) return Type.Date;
+ if(style == DateAnswerStyle.DateAndTime) return Type.DateAndTime;
+ if(style == DateAnswerStyle.TimeOfDay) return Type.TimeOfDay;
return Type.None;
}
- public BodyAnswer validateAnswer(Date resultDate) {
- if (minimumDate != null && resultDate.getTime() < minimumDate.getTime()) {
+ public BodyAnswer validateAnswer(Date resultDate)
+ {
+ if(minimumDate != null && resultDate.getTime() < minimumDate.getTime())
+ {
return new BodyAnswer(false,
- R.string.rsb_invalid_answer_date_under,
- FormatHelper.SIMPLE_FORMAT_DATE.format(minimumDate));
+ R.string.rsb_invalid_answer_date_under,
+ FormatHelper.SIMPLE_FORMAT_DATE.format(minimumDate));
}
- if (maximumDate != null && resultDate.getTime() > maximumDate.getTime()) {
+ if(maximumDate != null && resultDate.getTime() > maximumDate.getTime())
+ {
return new BodyAnswer(false,
- R.string.rsb_invalid_answer_date_over,
- FormatHelper.SIMPLE_FORMAT_DATE.format(maximumDate));
+ R.string.rsb_invalid_answer_date_over,
+ FormatHelper.SIMPLE_FORMAT_DATE.format(maximumDate));
}
return BodyAnswer.VALID;
}
-}
+}
\ No newline at end of file
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/DecimalAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/DecimalAnswerFormat.java
index 5c7714f50..16c61f5e4 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/DecimalAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/DecimalAnswerFormat.java
@@ -16,6 +16,12 @@ public class DecimalAnswerFormat extends AnswerFormat {
private float minValue;
private float maxValue;
+ /* Default constructor needed for serilization/deserialization of object */
+ DecimalAnswerFormat()
+ {
+ super();
+ }
+
/**
* Creates an answer format with the specified min and max values
*
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/DurationAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/DurationAnswerFormat.java
index 94a6c3fd5..73717bd01 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/DurationAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/DurationAnswerFormat.java
@@ -7,12 +7,17 @@ public class DurationAnswerFormat extends AnswerFormat {
private String unit;
private int step;
+ /* Default constructor needed for serilization/deserialization of object */
+ DurationAnswerFormat()
+ {
+ super();
+ }
+
public DurationAnswerFormat(int step, String unit) {
this.step = step;
this.unit = unit;
}
-
@Override
public QuestionType getQuestionType() {
return Type.Duration;
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/EmailAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/EmailAnswerFormat.java
index 5b7585b33..bfa496833 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/EmailAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/EmailAnswerFormat.java
@@ -1,13 +1,17 @@
package org.researchstack.backbone.answerformat;
+import android.text.InputType;
+
import org.researchstack.backbone.utils.TextUtils;
public class EmailAnswerFormat extends TextAnswerFormat {
private static final int MAX_EMAIL_LENGTH = 255;
+ /* Default constructor needed for serilization/deserialization of object */
public EmailAnswerFormat() {
super(MAX_EMAIL_LENGTH);
+ setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS);
}
@Override
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/FormAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/FormAnswerFormat.java
index dcd6fb85d..30381a089 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/FormAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/FormAnswerFormat.java
@@ -6,10 +6,9 @@
* answers.
*/
public class FormAnswerFormat extends AnswerFormat {
- /**
- * Default constructor
- */
+ /* Default constructor needed for serilization/deserialization of object */
public FormAnswerFormat() {
+ super();
}
@Override
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/GenderAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/GenderAnswerFormat.java
new file mode 100644
index 000000000..dea0ca19b
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/GenderAnswerFormat.java
@@ -0,0 +1,51 @@
+package org.researchstack.backbone.answerformat;
+
+import android.content.Context;
+
+import org.researchstack.backbone.model.Choice;
+import org.researchstack.backbone.model.UserHealth;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class GenderAnswerFormat extends ChoiceAnswerFormat {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ GenderAnswerFormat()
+ {
+ super();
+ }
+
+ public GenderAnswerFormat(Context context) {
+ super(ChoiceAnswerStyle.SingleChoice, createChoices(context));
+ }
+
+ /**
+ * @param context used to build strings for male, female, other
+ * @return Choice array for gender
+ */
+ static Choice[] createChoices(Context context) {
+ String female = UserHealth.Gender.FEMALE.localizedTitle(context);
+ String male = UserHealth.Gender.MALE.localizedTitle(context);
+ String other = UserHealth.Gender.OTHER.localizedTitle(context);
+
+ Choice[] genderChoices = new Choice[3];
+ genderChoices[0] = new Choice(female, female);
+ genderChoices[1] = new Choice(male, male);
+ genderChoices[2] = new Choice(other, other);
+
+ return genderChoices;
+ }
+
+ /**
+ * Creates an answer format with the specified answerStyle(single or multichoice) and collection
+ * of choices.
+ *
+ * @param answerStyle either MultipleChoice or SingleChoice
+ * @param choices an array of {@link Choice} objects, all of the same type
+ */
+ public GenderAnswerFormat(ChoiceAnswerStyle answerStyle, Choice... choices) {
+ super(answerStyle, choices);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/ImageChoiceAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/ImageChoiceAnswerFormat.java
new file mode 100644
index 000000000..03800e06a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/ImageChoiceAnswerFormat.java
@@ -0,0 +1,159 @@
+package org.researchstack.backbone.answerformat;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 3/14/17.
+ *
+ * The `ImageChoiceAnswerFormat` class represents an answer format that lets participants choose
+ * one image from a fixed set of images in a single choice question.
+ *
+ * For example, you might use the image choice answer format to represent a range of moods that range
+ * from very sad to very happy.
+ *
+ * The image choice answer format produces an `ChoiceQuestionResult` object.
+ */
+
+public class ImageChoiceAnswerFormat extends AnswerFormat {
+
+ /**
+ * An array of `ImageChoice` objects that represent the available choices.
+ *
+ * The text of the currently selected choice is displayed on screen. The text for
+ * each choice is spoken by VoiceOver when an image is highlighted.
+ */
+ private List imageChoiceList;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public ImageChoiceAnswerFormat() {
+ super();
+ }
+
+ /**
+ * Returns an initialized image choice answer format using the specified array of images.
+ *
+ * @param imageChoiceList List of `ImageChoice` objects.
+ *
+ * @return An initialized image choice answer format.
+ */
+ public ImageChoiceAnswerFormat(List imageChoiceList) {
+ this.imageChoiceList = imageChoiceList;
+ }
+
+ public List getImageChoiceList() {
+ return imageChoiceList;
+ }
+
+ public void setImageChoiceList(List imageChoiceList) {
+ this.imageChoiceList = imageChoiceList;
+ }
+
+ @Override
+ public QuestionType getQuestionType() {
+ return Type.ImageChoice;
+ }
+
+ /**
+ * The `ImageChoice` class defines a choice that can be included in an `ImageChoiceAnswerFormat` object.
+ *
+ * Typically, image choices are displayed in a horizontal row, so you need to use appropriate sizes.
+ * For example, when five image choices are displayed in an `ImageChoiceAnswerFormat`, image sizes
+ * of about 45 to 60 points allow the images to look good in apps that run on all versions of iPhone.
+ *
+ * The text that describes an image choice should be reasonably short. However, only the text for the
+ * currently selected image choice is displayed, so text that wraps to more than one line
+ * is supported.
+ */
+ public static class ImageChoice implements Serializable {
+
+ private String text;
+
+ /**
+ * The image to display when the choice is not selected.
+ *
+ * The size of the unselected image depends on the number of choices you need to display. As a
+ * general rule, it's recommended that you start by creating an image that measures 44 x 44 points,
+ * and adjust it if necessary.
+ *
+ * String representation of a drawable resource
+ * for instance, if your image is R.drawable.image, this would just be "image"
+ */
+ private String normalImageRes;
+
+ /**
+ * The image to display when the choice is selected.
+ *
+ * For best results, the selected image should be the same size as the unselected image
+ * (that is, the value of the `normalImageRes` member variable).
+ * If you don't specify a selected image, the default tintColor is used to
+ * indicate the selection state of the item.
+ *
+ * String representation of a drawable resource
+ * for instance, if your image is R.drawable.image, this would just be "image"
+ */
+ private String selectedImageRes;
+
+ /**
+ * The value to return when the image is selected.
+ * The value of this variable is expected to be a scalar property list type, such as `Number` or
+ * `String`. If no value is provided, the index of the option as an 'Integer'
+ * in the `ImageChoiceAnswerFormat` options list is used.
+ */
+ private Serializable value;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public ImageChoice() {
+ super();
+ }
+
+ /**
+ * Returns an image choice that includes the specified images and text.
+ *
+ * @param normalImageRes The image drawable resource name to display in the unselected state.
+ * @param selectedImageRes The image drawable resource name to display in the selected state.
+ * @param text The text to display when the image is selected.
+ * @param value The value to record in a result object when the image is selected.
+ *
+ * @return An image choice instance.
+ */
+ public ImageChoice (String normalImageRes, String selectedImageRes, String text, Serializable value) {
+ this.normalImageRes = normalImageRes;
+ this.selectedImageRes = selectedImageRes;
+ this.text = text;
+ this.value = value;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ public void setText(String text) {
+ this.text = text;
+ }
+
+ public String getNormalImageRes() {
+ return normalImageRes;
+ }
+
+ public void setNormalImageRes(String normalImageRes) {
+ this.normalImageRes = normalImageRes;
+ }
+
+ public String getSelectedImageRes() {
+ return selectedImageRes;
+ }
+
+ public void setSelectedImageRes(String selectedImageRes) {
+ this.selectedImageRes = selectedImageRes;
+ }
+
+ public Serializable getValue() {
+ return value;
+ }
+
+ public void setValue(Serializable value) {
+ this.value = value;
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/IntegerAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/IntegerAnswerFormat.java
index 7ccaae7dd..ae5f1df1e 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/IntegerAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/IntegerAnswerFormat.java
@@ -1,8 +1,11 @@
package org.researchstack.backbone.answerformat;
+import android.text.InputType;
+
import org.researchstack.backbone.R;
import org.researchstack.backbone.ui.step.body.BodyAnswer;
import org.researchstack.backbone.utils.TextUtils;
+import org.w3c.dom.Text;
/**
* This class defines the attributes for an integer answer format that participants enter using a
@@ -12,9 +15,21 @@
* the {@link org.researchstack.backbone.ui.step.body.IntegerQuestionBody} does not allow navigation
* until the participant provides a value that is within the valid range.
*/
-public class IntegerAnswerFormat extends AnswerFormat {
+public class IntegerAnswerFormat extends TextAnswerFormat {
private int maxValue;
+ public void setMaxValue(int maxValue) {
+ this.maxValue = maxValue;
+ }
private int minValue;
+ public void setMinValue(int minValue) {
+ this.minValue = minValue;
+ }
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public IntegerAnswerFormat() {
+ super();
+ commonInit();
+ }
/**
* Creates an integer answer format with the specified min and max values.
@@ -23,22 +38,27 @@ public class IntegerAnswerFormat extends AnswerFormat {
* @param maxValue maximum allowed value, 0 if no max
*/
public IntegerAnswerFormat(int minValue, int maxValue) {
+ this();
this.minValue = minValue;
this.maxValue = maxValue;
}
+ private void commonInit() {
+ setInputType(InputType.TYPE_CLASS_NUMBER);
+ }
+
@Override
public QuestionType getQuestionType() {
return Type.Integer;
}
/**
- * Returns the maximum allowed value for the question, 0 if no max
+ * Returns the maximum allowed value for the question, Integer.MAX_VALUE if maxValue is 0
*
- * @return the max value, 0 if no max
+ * @return the max value, Integer.MAX_VALUE if maxValue is 0
*/
public int getMaxValue() {
- return maxValue;
+ return (maxValue == 0) ? Integer.MAX_VALUE : maxValue;
}
/**
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/MoodScaleAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/MoodScaleAnswerFormat.java
new file mode 100644
index 000000000..97bc9bc8c
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/MoodScaleAnswerFormat.java
@@ -0,0 +1,148 @@
+package org.researchstack.backbone.answerformat;
+
+import android.content.Context;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.utils.ResUtils;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Created by TheMDP on 3/14/17.
+ */
+
+public class MoodScaleAnswerFormat extends ImageChoiceAnswerFormat {
+
+ public static final int MOOD_IMAGE_COUNT = 5;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public MoodScaleAnswerFormat() {
+ super();
+ }
+
+ /**
+ * Creates a MoodScaleAnswerFormat using the CUSTOM images, but with different hint text
+ * and step result values
+ * @param customTextChoices list of custom hint text that will be displayed
+ * when the corresponding mood image is selected
+ * @param customValues lost of custom values that will be saved to the step result
+ * when the corresponding mood image is selected
+ */
+ public MoodScaleAnswerFormat(List customTextChoices, List customValues) {
+ if (customTextChoices.size() != MOOD_IMAGE_COUNT ||
+ customValues.size() != MOOD_IMAGE_COUNT)
+ {
+ throw new IllegalStateException("Both customTextChoices and customValues must" +
+ "be length " + MOOD_IMAGE_COUNT + " to match the images");
+ }
+
+ createImageChoiceList(ResUtils.MoodSurvey.CUSTOM, customTextChoices, customValues);
+ }
+
+ /**
+ * @param context Can be app or activity, used to grab string resources
+ * @param moodQuestionType the type of mood question to base images and text on
+ */
+ public MoodScaleAnswerFormat(Context context, MoodQuestionType moodQuestionType) {
+ super();
+
+ String imageName = null;
+ List textChoices = new ArrayList<>();
+
+ switch (moodQuestionType) {
+ case CLARITY:
+ imageName = ResUtils.MoodSurvey.CLARITY;
+ textChoices = Arrays.asList(
+ context.getString(R.string.rsb_MOOD_CLARITY_GREAT),
+ context.getString(R.string.rsb_MOOD_CLARITY_GOOD),
+ context.getString(R.string.rsb_MOOD_CLARITY_AVERAGE),
+ context.getString(R.string.rsb_MOOD_CLARITY_BAD),
+ context.getString(R.string.rsb_MOOD_CLARITY_TERRIBLE));
+ break;
+ case OVERALL:
+ imageName = ResUtils.MoodSurvey.OVERALL;
+ textChoices = Arrays.asList(
+ context.getString(R.string.rsb_MOOD_OVERALL_GREAT),
+ context.getString(R.string.rsb_MOOD_OVERALL_GOOD),
+ context.getString(R.string.rsb_MOOD_OVERALL_AVERAGE),
+ context.getString(R.string.rsb_MOOD_OVERALL_BAD),
+ context.getString(R.string.rsb_MOOD_OVERALL_TERRIBLE));
+ break;
+ case PAIN:
+ imageName = ResUtils.MoodSurvey.PAIN;
+ textChoices = Arrays.asList(
+ context.getString(R.string.rsb_MOOD_PAIN_GREAT),
+ context.getString(R.string.rsb_MOOD_PAIN_GOOD),
+ context.getString(R.string.rsb_MOOD_PAIN_AVERAGE),
+ context.getString(R.string.rsb_MOOD_PAIN_BAD),
+ context.getString(R.string.rsb_MOOD_PAIN_TERRIBLE));
+ break;
+ case SLEEP:
+ imageName = ResUtils.MoodSurvey.SLEEP;
+ textChoices = Arrays.asList(
+ context.getString(R.string.rsb_MOOD_SLEEP_GREAT),
+ context.getString(R.string.rsb_MOOD_SLEEP_GOOD),
+ context.getString(R.string.rsb_MOOD_SLEEP_AVERAGE),
+ context.getString(R.string.rsb_MOOD_SLEEP_BAD),
+ context.getString(R.string.rsb_MOOD_SLEEP_TERRIBLE));
+ break;
+ case EXERCISE:
+ imageName = ResUtils.MoodSurvey.EXERCISE;
+ textChoices = Arrays.asList(
+ context.getString(R.string.rsb_MOOD_EXERCISE_GREAT),
+ context.getString(R.string.rsb_MOOD_EXERCISE_GOOD),
+ context.getString(R.string.rsb_MOOD_EXERCISE_AVERAGE),
+ context.getString(R.string.rsb_MOOD_EXERCISE_BAD),
+ context.getString(R.string.rsb_MOOD_EXERCISE_TERRIBLE));
+ break;
+ case CUSTOM:
+ imageName = ResUtils.MoodSurvey.CUSTOM;
+ textChoices = Arrays.asList(
+ context.getString(R.string.rsb_MOOD_CUSTOM_GREAT),
+ context.getString(R.string.rsb_MOOD_CUSTOM_GOOD),
+ context.getString(R.string.rsb_MOOD_CUSTOM_AVERAGE),
+ context.getString(R.string.rsb_MOOD_CUSTOM_BAD),
+ context.getString(R.string.rsb_MOOD_CUSTOM_TERRIBLE));
+ break;
+ }
+
+ createImageChoiceList(imageName, textChoices, null);
+ }
+
+ private void createImageChoiceList(
+ String imageName, List textChoices, List values)
+ {
+ List imageChoiceList = new ArrayList<>();
+ int count = textChoices.size();
+ for (int i = 0; i < count; i++) {
+ String normalNameRes = ResUtils.MoodSurvey.normal(imageName, (i + 1));
+ Serializable value = Integer.valueOf(count - i);
+ if (values != null) {
+ value = values.get(i);
+ }
+ ImageChoice answerOption = new ImageChoice(
+ normalNameRes,
+ null,
+ textChoices.get(i),
+ value);
+ imageChoiceList.add(answerOption);
+ }
+ setImageChoiceList(imageChoiceList);
+ }
+
+ /**
+ * Type of mood survey question.
+ */
+ public enum MoodQuestionType {
+ CUSTOM,
+ CLARITY,
+ OVERALL,
+ PAIN,
+ SLEEP,
+ EXERCISE
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/PasswordAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/PasswordAnswerFormat.java
new file mode 100644
index 000000000..0cbbb95a3
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/PasswordAnswerFormat.java
@@ -0,0 +1,28 @@
+package org.researchstack.backbone.answerformat;
+
+import android.text.InputType;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class PasswordAnswerFormat extends TextAnswerFormat {
+
+ static final int DEFAULT_PASSWORD_MIN_LENGTH = 4;
+ static final int DEFAULT_PASSWORD_MAX_LENGTH = 16;
+ static final String PASSWORD_VALIDATION_REGEX = "^\\p{ASCII}*$";
+
+ /**
+ * Creates a TextAnswerFormat with no maximum length
+ * Also, default constructor needed for serilization/deserialization of object
+ */
+ public PasswordAnswerFormat()
+ {
+ super();
+ setMinumumLength(DEFAULT_PASSWORD_MIN_LENGTH);
+ setMaximumLength(DEFAULT_PASSWORD_MAX_LENGTH);
+ setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
+ setIsMultipleLines(false);
+ setValidationRegex(PASSWORD_VALIDATION_REGEX);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/answerformat/TextAnswerFormat.java b/backbone/src/main/java/org/researchstack/backbone/answerformat/TextAnswerFormat.java
index 3cdda7726..e4885d9b8 100644
--- a/backbone/src/main/java/org/researchstack/backbone/answerformat/TextAnswerFormat.java
+++ b/backbone/src/main/java/org/researchstack/backbone/answerformat/TextAnswerFormat.java
@@ -1,6 +1,8 @@
package org.researchstack.backbone.answerformat;
+import android.text.InputType;
+
/**
* The TextAnswerFormat class represents the answer format for questions that collect a text
* response from the user.
@@ -8,11 +10,16 @@
public class TextAnswerFormat extends AnswerFormat {
public static final int UNLIMITED_LENGTH = 0;
private int maximumLength;
+ private int minimumLength = 0;
private boolean isMultipleLines = false;
+ private int inputType = InputType.TYPE_CLASS_TEXT;
+ private String validationRegex = null;
+ private boolean disabled;
/**
* Creates a TextAnswerFormat with no maximum length
+ * Also, default constructor needed for serilization/deserialization of object
*/
public TextAnswerFormat() {
this(UNLIMITED_LENGTH);
@@ -36,6 +43,32 @@ public int getMaximumLength() {
return maximumLength;
}
+ /**
+ * @param maximumLength the maximum length for the answer, 0 if no maximum set
+ */
+ public void setMaximumLength(int maximumLength)
+ {
+ this.maximumLength = maximumLength;
+ }
+
+ /**
+ * Returns the minimum length for the answer, 0 if no minumum set
+ *
+ * @return the minumum
+ */
+ public int getMinumumLength()
+ {
+ return minimumLength;
+ }
+
+ /**
+ * @param minimumLength minimum length for the answer, 0 if no minumum set
+ */
+ public void setMinumumLength(int minimumLength)
+ {
+ this.minimumLength = minimumLength;
+ }
+
@Override
public QuestionType getQuestionType() {
return Type.Text;
@@ -59,6 +92,40 @@ public boolean isMultipleLines() {
return isMultipleLines;
}
+ /**
+ * @param validationRegex used to validate the text answer
+ */
+ public void setValidationRegex(String validationRegex)
+ {
+ this.validationRegex = validationRegex;
+ }
+
+ /**
+ * Returns whether validation regex for text answer
+ *
+ * @return String which can be null
+ */
+ public String validationRegex()
+ {
+ return validationRegex;
+ }
+
+ /**
+ * @return int indicating the input type of the text answer format
+ */
+ public int getInputType()
+ {
+ return inputType;
+ }
+
+ /**
+ * @param inputType indicating the input type used for this format
+ */
+ public void setInputType(int inputType)
+ {
+ this.inputType = inputType;
+ }
+
/**
* Returns a boolean indicating whether the passed in text is valid based on this answer format
*
@@ -66,7 +133,25 @@ public boolean isMultipleLines() {
* @return a boolean indicating if the answer is valid
*/
public boolean isAnswerValid(String text) {
- return text != null && text.length() > 0 &&
+ boolean valid = text != null && text.length() >= minimumLength &&
(maximumLength == UNLIMITED_LENGTH || text.length() <= maximumLength);
+
+ if (valid == false) {
+ return valid;
+ }
+
+ if (validationRegex != null) {
+ valid = text.matches(validationRegex);
+ }
+
+ return valid;
+ }
+
+ public boolean isDisabled() {
+ return disabled;
+ }
+
+ public void setDisabled(boolean disabled) {
+ this.disabled = disabled;
}
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/factory/IntentFactory.java b/backbone/src/main/java/org/researchstack/backbone/factory/IntentFactory.java
new file mode 100644
index 000000000..8945fe9ec
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/factory/IntentFactory.java
@@ -0,0 +1,86 @@
+package org.researchstack.backbone.factory;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.annotation.NonNull;
+
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.task.Task;
+import org.researchstack.backbone.ui.ViewTaskActivity;
+
+/**
+ * This class encapsulates creating Intent instances. This is needed to enable unit tests, since
+ * Intents in the unit test environment will always throw.
+ */
+public class IntentFactory {
+ /** Singleton instance. */
+ public static final IntentFactory INSTANCE = new IntentFactory();
+
+ /**
+ * Private constructor, to enforce the singleton property. This prevents creating additional
+ * instances, but the factory can still be mocked.
+ */
+ private IntentFactory() {
+ }
+
+ /**
+ * Creates an Intent for a task activity.
+ *
+ * @param context activity context
+ * @param clazz activity class
+ * @param task task activity
+ * @return an Intent for this task activity
+ */
+ @NonNull
+ public Intent newTaskIntent(Context context, Class extends ViewTaskActivity> clazz, Task task) {
+ Intent intent = new Intent(context, clazz);
+ intent.putExtra(ViewTaskActivity.EXTRA_TASK, task);
+ return intent;
+ }
+
+ /**
+ * Creates an Intent for a task activity with an existing task result
+ *
+ * @param context activity context
+ * @param clazz activity class
+ * @param task task activity
+ * @param result existing task result
+ * @return an Intent for this task activity
+ */
+ @NonNull
+ public Intent newTaskIntent(Context context, Class extends ViewTaskActivity> clazz, Task task, TaskResult result) {
+ Intent intent = new Intent(context, clazz);
+ intent.putExtra(ViewTaskActivity.EXTRA_TASK, task);
+ if (result != null) {
+ intent.putExtra(ViewTaskActivity.EXTRA_TASK_RESULT, result);
+ }
+ return intent;
+ }
+
+ /**
+ * Creates an Intent for a task activity with an existing task result
+ *
+ * @param context activity context
+ * @param clazz activity class
+ * @param task task activity
+ * @param result existing task result
+ * @param step the step the task should start on
+ * @return an Intent for this task activity
+ */
+ @NonNull
+ public Intent newTaskIntent(Context context,
+ Class extends ViewTaskActivity> clazz,
+ Task task, TaskResult result, Step step) {
+
+ Intent intent = new Intent(context, clazz);
+ intent.putExtra(ViewTaskActivity.EXTRA_TASK, task);
+ if (result != null) {
+ intent.putExtra(ViewTaskActivity.EXTRA_TASK_RESULT, result);
+ }
+ if (step != null) {
+ intent.putExtra(ViewTaskActivity.EXTRA_STEP, step);
+ }
+ return intent;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/factory/ObservableTransformerFactory.java b/backbone/src/main/java/org/researchstack/backbone/factory/ObservableTransformerFactory.java
new file mode 100644
index 000000000..0da152f82
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/factory/ObservableTransformerFactory.java
@@ -0,0 +1,27 @@
+package org.researchstack.backbone.factory;
+
+import androidx.annotation.NonNull;
+import org.researchstack.backbone.utils.ObservableUtils;
+import rx.Observable;
+
+/**
+ * This class encapsulates creating Observable.Transformer instances. This is needed to enable unit
+ * tests, since the default transformer calls through to Schedulers.io() and Android.mainThread().
+ */
+public class ObservableTransformerFactory {
+ /** Singleton instance. */
+ public static final ObservableTransformerFactory INSTANCE = new ObservableTransformerFactory();
+
+ /**
+ * Private constructor, to enforce the singleton property. This prevents creating additional
+ * instances, but the factory can still be mocked.
+ */
+ private ObservableTransformerFactory() {
+ }
+
+ /** Applies the default transformers. Used with Observable.compose(). */
+ @NonNull
+ public Observable.Transformer defaultTransformer() {
+ return ObservableUtils.applyDefault();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/Choice.java b/backbone/src/main/java/org/researchstack/backbone/model/Choice.java
index e4c820e9b..840d55ab2 100644
--- a/backbone/src/main/java/org/researchstack/backbone/model/Choice.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/Choice.java
@@ -1,5 +1,7 @@
package org.researchstack.backbone.model;
+import com.google.gson.annotations.SerializedName;
+
import java.io.Serializable;
/**
@@ -7,15 +9,24 @@
* They typically have an integer or string value, always with a string text representation of the
* choice for the user.
*
- * @param the type of value for the choice, usually Integer or String
+ * @param the type of value for the choice, usually Integer or String, but must implement Serializable
*/
public class Choice implements Serializable {
+
+ @SerializedName(value="text", alternate = {"prompt"})
private String text;
+ @SerializedName("value")
private T value;
+ @SerializedName("detailText")
private String detailText;
+ /* Default constructor needed for serilization/deserialization of object */
+ Choice() {
+ super();
+ }
+
/**
* Creates a choice object with the provided text and value, detailtext is null
*
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentDocument.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentDocument.java
index c83f713dc..a538d6550 100644
--- a/backbone/src/main/java/org/researchstack/backbone/model/ConsentDocument.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentDocument.java
@@ -1,6 +1,8 @@
package org.researchstack.backbone.model;
-import android.support.annotation.StringRes;
+import androidx.annotation.StringRes;
+
+import com.google.gson.annotations.SerializedName;
import java.io.Serializable;
import java.util.ArrayList;
@@ -66,6 +68,13 @@ public class ConsentDocument implements Serializable {
*/
private String htmlReviewContent;
+ @SerializedName("documentProperties")
+ DocumentProperties documentProperties;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public ConsentDocument() {
+ super();
+ }
public void setTitle(String title) {
this.title = title;
@@ -114,4 +123,7 @@ public void setHtmlReviewContent(String htmlReviewContent) {
this.htmlReviewContent = htmlReviewContent;
}
+ public DocumentProperties getDocumentProperties() {
+ return documentProperties;
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentQuestionType.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentQuestionType.java
new file mode 100644
index 000000000..2b3ca9dd5
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentQuestionType.java
@@ -0,0 +1,27 @@
+package org.researchstack.backbone.model;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 12/15/16.
+ */
+
+@Deprecated // no longer needed since ConsentQuizModel is deprecated
+public enum ConsentQuestionType {
+
+ @SerializedName("boolean")
+ BOOLEAN("boolean"),
+ @SerializedName("singleChoiceText")
+ SINGLE_CHOICE_TEXT("singleChoiceText"),
+ @SerializedName("instruction")
+ INSTRUCTION("instruction");
+
+ ConsentQuestionType(String questionId) {
+ mIdentifier = questionId;
+ }
+
+ String mIdentifier;
+ public String getIdentifier() {
+ return mIdentifier;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentQuizModel.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentQuizModel.java
new file mode 100644
index 000000000..8324489ca
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentQuizModel.java
@@ -0,0 +1,141 @@
+package org.researchstack.backbone.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Deprecated // Use NavigationFormStep or NavigationSubtaskStep instead
+public class ConsentQuizModel implements Serializable
+{
+ private String failureTitle;
+ private String failureMessage;
+ private String successTitle;
+ private String successMessage;
+ private int allowedFailures;
+ private List questions;
+
+ // fields with defaults
+ private String incorrectIcon = "rsb_quiz_retry";
+ private String correctIcon = "rsb_ic_quiz_valid";
+
+ ConsentQuizModel() {
+ super();
+ }
+
+ public String getFailureTitle()
+ {
+ return failureTitle;
+ }
+
+ public String getFailureMessage()
+ {
+ return failureMessage;
+ }
+
+ public String getSuccessTitle()
+ {
+ return successTitle;
+ }
+
+ public String getSuccessMessage()
+ {
+ return successMessage;
+ }
+
+ public int getAllowedFailures()
+ {
+ return allowedFailures;
+ }
+
+ public List getQuestions()
+ {
+ return questions;
+ }
+
+ public String getIncorrectIcon()
+ {
+ return incorrectIcon;
+ }
+
+ public String getCorrectIcon()
+ {
+ return correctIcon;
+ }
+
+ public class QuizQuestion implements Serializable
+ {
+ private String identifier;
+
+ /** iOS named it "title" but Android named it prompt, so allow for parsing of both */
+ @SerializedName(value="prompt", alternate = {"title"})
+ private String prompt;
+
+ private ConsentQuestionType type;
+ private String expectedAnswer;
+ private String text;
+ private String positiveFeedback;
+ private String negativeFeedback;
+
+ /**
+ * There are multiple ways you can provide the options for a quiz question:
+ * textChoices - a simple String list of choices for the user, the answer
+ * format will be the index in the array of the selected item
+ */
+ private List textChoices;
+ /**
+ * There are multiple ways you can provide the options for a quiz question:
+ * items - a list of text / value pairs that can provide any type of answer value per text
+ */
+ private List items;
+
+ public String getIdentifier()
+ {
+ return identifier;
+ }
+
+ public void setIdentifier(String identifier)
+ {
+ this.identifier = identifier;
+ }
+
+ public String getPrompt()
+ {
+ return prompt;
+ }
+
+ public ConsentQuestionType getType()
+ {
+ return type;
+ }
+
+ public String getExpectedAnswer()
+ {
+ return expectedAnswer;
+ }
+
+ public String getText()
+ {
+ return text;
+ }
+
+ public List getTextChoices()
+ {
+ return textChoices;
+ }
+
+ public List getItems() {
+ return items;
+ }
+
+ public String getPositiveFeedback()
+ {
+ return positiveFeedback == null ? "" : positiveFeedback;
+ }
+
+ public String getNegativeFeedback()
+ {
+ return negativeFeedback == null ? "" : negativeFeedback;
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentSection.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSection.java
index 13f232117..ea00bde60 100644
--- a/backbone/src/main/java/org/researchstack/backbone/model/ConsentSection.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSection.java
@@ -1,4 +1,5 @@
package org.researchstack.backbone.model;
+import androidx.annotation.StringRes;
import com.google.gson.annotations.SerializedName;
@@ -7,7 +8,8 @@
import java.io.Serializable;
-public class ConsentSection implements Serializable {
+public class ConsentSection implements Serializable
+{
/**
* The type of section. (read-only)
@@ -15,8 +17,10 @@ public class ConsentSection implements Serializable {
* The value of this property indicates whether a predefined image, title, and animation are
* present.
*/
- @SerializedName("sectionType")
- private Type type;
+ public static final String SECTION_TYPE_GSON = "sectionType";
+ @SerializedName(SECTION_TYPE_GSON)
+ private Type type;
+
/**
* The title of the consent section in a localized string.
*
@@ -102,75 +106,121 @@ public class ConsentSection implements Serializable {
@SerializedName("sectionAnimationUrl")
private String customAnimationURL;
+ /**
+ * Used for storing custom type identifer when type is CUSTOM eum
+ */
+ transient String customTypeIdentifier;
+
+ /* Default identifier for serilization/deserialization */
+ ConsentSection() {
+ super();
+ }
+
/**
* Returns an initialized consent section using the specified type.
*
* @param type The consent section type.
*/
- public ConsentSection(Type type) {
+ public ConsentSection(Type type)
+ {
this.type = type;
this.summary = null;
}
- public String getTitle() {
+ public String getTitle()
+ {
return title;
}
- public void setTitle(String title) {
+ public void setTitle(String title)
+ {
this.title = title;
}
- public String getFormalTitle() {
+ public String getFormalTitle()
+ {
return formalTitle;
}
- public Type getType() {
+ public Type getType()
+ {
return type;
}
+ void setType(Type type) {
+ this.type = type;
+ }
- public String getHtmlContent() {
+ public String getHtmlContent()
+ {
return htmlContent;
}
- public void setHtmlContent(String htmlContent) {
+ public void setHtmlContent(String htmlContent)
+ {
this.htmlContent = htmlContent;
}
- public String getCustomImageName() {
+ public String getCustomImageName()
+ {
return customImageName;
}
- public String getContent() {
+ void setCustomImageName(String imageName) {
+ customImageName = imageName;
+ }
+
+ public String getContent()
+ {
return content;
}
- public void setContent(String content) {
+ public void setContent(String content)
+ {
this.content = content;
this.escapedContent = null;
}
- public String getSummary() {
+ public String getSummary()
+ {
return summary;
}
- public void setSummary(String summary) {
+ public void setSummary(String summary)
+ {
this.summary = summary;
}
- public String getEscapedContent() {
+ public String getEscapedContent()
+ {
// If its null, return that. If not, escape/replace chars in var content
- if (TextUtils.isEmpty(content)) {
+ if(TextUtils.isEmpty(content))
+ {
return content;
}
return escapedContent;
}
- public String getCustomLearnMoreButtonTitle() {
+ public String getCustomLearnMoreButtonTitle()
+ {
return customLearnMoreButtonTitle;
}
- public enum Type implements Serializable {
+ public void setCustomLearnMoreButtonTitle(String customLearnMoreButtonTitle)
+ {
+ this.customLearnMoreButtonTitle = customLearnMoreButtonTitle;
+ }
+
+ public String getTypeIdentifier() {
+ if (type == Type.Custom) {
+ return customTypeIdentifier;
+ }
+ return type.getIdentifier();
+ }
+
+ public static final int UNDEFINED_RES = -1;
+ public enum Type implements Serializable
+ {
/**
* Overview of the informed consent process.
*
@@ -178,7 +228,11 @@ public enum Type implements Serializable {
* general background information on the purpose of the study.
*/
@SerializedName("overview")
- Overview(),
+ Overview(
+ "overview",
+ R.string.rsb_consent_section_welcome,
+ R.string.rsb_consent_section_more_info_welcome,
+ null),
/**
* A section informing the user that sensor data will be collected.
@@ -187,7 +241,11 @@ public enum Type implements Serializable {
* purpose.
*/
@SerializedName("dataGathering")
- DataGathering(),
+ DataGathering(
+ "dataGathering",
+ R.string.rsb_consent_section_data_gathering,
+ R.string.rsb_consent_section_more_info_data_gathering,
+ "rsb_consent_section_data_gathering"),
/**
* A section describing the privacy policies for the study.
@@ -198,7 +256,11 @@ public enum Type implements Serializable {
* involved.
*/
@SerializedName("privacy")
- Privacy(),
+ Privacy(
+ "privacy",
+ R.string.rsb_consent_section_privacy,
+ R.string.rsb_consent_section_more_info_privacy,
+ "rsb_consent_section_privacy"),
/**
* A section describing how the collected data will be used.
@@ -208,7 +270,11 @@ public enum Type implements Serializable {
* over the data after it is collected.
*/
@SerializedName("dataUse")
- DataUse(),
+ DataUse(
+ "dataUse",
+ R.string.rsb_consent_section_data_use,
+ R.string.rsb_consent_section_more_info_data_use,
+ "rsb_consent_section_data_use"),
/**
* A section describing how much time is required for the study.
@@ -216,7 +282,11 @@ public enum Type implements Serializable {
* This content can help users understand what to expect as they participate in the study.
*/
@SerializedName("timeCommitment")
- TimeCommitment(),
+ TimeCommitment(
+ "timeCommitment",
+ R.string.rsb_consent_section_time_commitment,
+ R.string.rsb_consent_section_more_info_time_commitment,
+ "rsb_consent_section_time_commitment"),
/**
* A section describing active task use in the study.
@@ -225,7 +295,11 @@ public enum Type implements Serializable {
* what purpose. Any risks that are involved can also be communicated in this section.
*/
@SerializedName("studyTasks")
- StudyTasks(),
+ StudyTasks(
+ "studyTasks",
+ R.string.rsb_consent_section_study_tasks,
+ R.string.rsb_consent_section_more_info_study_tasks,
+ "rsb_consent_section_study_tasks"),
/**
* A section describing survey use in the study.
@@ -234,7 +308,11 @@ public enum Type implements Serializable {
* clear to what extent participation is optional.
*/
@SerializedName("studySurvey")
- StudySurvey(),
+ StudySurvey(
+ "studySurvey",
+ R.string.rsb_consent_section_study_survey,
+ R.string.rsb_consent_section_more_info_study_survey,
+ "rsb_consent_section_study_survey"),
/**
* A section describing how to withdraw from the study.
@@ -243,7 +321,11 @@ public enum Type implements Serializable {
* to withdraw.
*/
@SerializedName("withdrawing")
- Withdrawing(),
+ Withdrawing(
+ "withdrawing",
+ R.string.rsb_consent_section_withdrawing,
+ R.string.rsb_consent_section_more_info_withdrawing,
+ "rsb_consent_section_withdrawing"),
/**
* A custom section.
@@ -252,7 +334,7 @@ public enum Type implements Serializable {
* consent document may have as many or as few custom sections as needed.
*/
@SerializedName("custom")
- Custom,
+ Custom("custom", UNDEFINED_RES, R.string.rsb_consent_section_more_info, null),
/**
* Document-only sections.
@@ -262,75 +344,37 @@ public enum Type implements Serializable {
* property).
*/
@SerializedName("onlyInDocument")
- OnlyInDocument;
+ OnlyInDocument("onlyInDocument", UNDEFINED_RES, R.string.rsb_consent_section_more_info, null);
+
+ Type(
+ String identifier,
+ @StringRes int titleRes,
+ @StringRes int moreInfoRes,
+ String imageName)
+ {
+ this.identifier = identifier;
+ this.titleRes = titleRes;
+ this.moreInfoRes = moreInfoRes;
+ this.imageName = imageName;
+ }
+
+ String identifier;
+ String imageName;
+ @StringRes int titleRes;
+ @StringRes int moreInfoRes;
public int getTitleResId() {
- switch (this) {
- case Overview:
- return R.string.rsb_consent_section_welcome;
- case DataGathering:
- return R.string.rsb_consent_section_data_gathering;
- case Privacy:
- return R.string.rsb_consent_section_privacy;
- case DataUse:
- return R.string.rsb_consent_section_data_use;
- case TimeCommitment:
- return R.string.rsb_consent_section_time_commitment;
- case StudySurvey:
- return R.string.rsb_consent_section_study_survey;
- case StudyTasks:
- return R.string.rsb_consent_section_study_tasks;
- case Withdrawing:
- return R.string.rsb_consent_section_withdrawing;
- default:
- return -1;
- }
+ return titleRes;
}
-
public String getImageName() {
- switch (this) {
- case DataGathering:
- return "rsb_consent_section_data_gathering";
- case Privacy:
- return "rsb_consent_section_privacy";
- case DataUse:
- return "rsb_consent_section_data_use";
- case TimeCommitment:
- return "rsb_consent_section_time_commitment";
- case StudySurvey:
- return "rsb_consent_section_study_survey";
- case StudyTasks:
- return "rsb_consent_section_study_tasks";
- case Withdrawing:
- return "rsb_consent_section_withdrawing";
- default:
- return null;
- }
+ return imageName;
}
-
public int getMoreInfoResId() {
- switch (this) {
- case Overview:
- return R.string.rsb_consent_section_more_info_welcome;
- case DataGathering:
- return R.string.rsb_consent_section_more_info_data_gathering;
- case Privacy:
- return R.string.rsb_consent_section_more_info_privacy;
- case DataUse:
- return R.string.rsb_consent_section_more_info_data_use;
- case TimeCommitment:
- return R.string.rsb_consent_section_more_info_time_commitment;
- case StudySurvey:
- return R.string.rsb_consent_section_more_info_study_survey;
- case StudyTasks:
- return R.string.rsb_consent_section_more_info_study_tasks;
- case Withdrawing:
- return R.string.rsb_consent_section_more_info_withdrawing;
- default:
- return R.string.rsb_consent_section_more_info;
- }
+ return moreInfoRes;
+ }
+ private String getIdentifier() {
+ return identifier;
}
-
}
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentSectionAdapter.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSectionAdapter.java
new file mode 100644
index 000000000..4c47d90eb
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSectionAdapter.java
@@ -0,0 +1,79 @@
+package org.researchstack.backbone.model;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+
+import org.researchstack.backbone.ResourceManager;
+import org.researchstack.backbone.ResourcePathManager;
+import org.researchstack.backbone.onboarding.OnboardingManager;
+
+import java.lang.reflect.Type;
+
+/**
+ * Created by TheMDP on 1/5/17.
+ */
+
+public class ConsentSectionAdapter implements JsonDeserializer {
+
+ /**
+ * Used to convert ConsentSections
+ */
+ OnboardingManager.AdapterContextProvider adapterProvider;
+
+ public ConsentSectionAdapter(OnboardingManager.AdapterContextProvider adapterProvider) {
+ this.adapterProvider = adapterProvider;
+ }
+
+ @Override
+ public ConsentSection deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+
+ JsonObject jsonObject = json.getAsJsonObject();
+ JsonElement typeJson = jsonObject.get(ConsentSection.SECTION_TYPE_GSON);
+
+ ConsentSection.Type type = context.deserialize(typeJson, ConsentSection.Type.class);
+ // This was a custom ConsentSection Type
+ if (type == null) {
+ type = ConsentSection.Type.Custom;
+ }
+
+ // This will avoid the infinite loop of using the param json context,
+ // Since this wont have the ConsentSectionAdapter registered
+ Gson gson = new Gson();
+ ConsentSection consentSection = gson.fromJson(json, ConsentSection.class);
+ consentSection.setType(type);
+
+ // If we have a non-custom type, we can auto-populate title, learn more, and image name
+ // if they weren't specifically provided by the JSON
+ if (type != ConsentSection.Type.Custom) {
+ if (adapterProvider != null && adapterProvider.getContext() != null) {
+ if (consentSection.getTitle() == null && type.getTitleResId() != ConsentSection.UNDEFINED_RES) {
+ consentSection.setTitle(adapterProvider.getContext().getString(type.getTitleResId()));
+ }
+ if (consentSection.getCustomLearnMoreButtonTitle() == null && type.getMoreInfoResId() != ConsentSection.UNDEFINED_RES) {
+ consentSection.setCustomLearnMoreButtonTitle(adapterProvider.getContext().getString(type.getMoreInfoResId()));
+ }
+ }
+ if (consentSection.getCustomImageName() == null) {
+ consentSection.setCustomImageName(type.getImageName());
+ }
+ } else {
+ consentSection.customTypeIdentifier = typeJson.getAsString();
+ }
+
+ // Convert HTML content from filename to actual HTML content
+ if (consentSection.getHtmlContent() != null &&
+ adapterProvider != null && adapterProvider.getContext() != null)
+ {
+ String htmlContentPath = ResourceManager.getInstance().generatePath(
+ ResourcePathManager.Resource.TYPE_HTML, consentSection.getHtmlContent());
+ String htmlContent = ResourceManager.getResourceAsString(adapterProvider.getContext(), htmlContentPath);
+ consentSection.setHtmlContent(htmlContent);
+ }
+
+ return consentSection;
+ }
+}
diff --git a/skin/src/main/java/org/researchstack/skin/model/ConsentSectionModel.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSectionModel.java
similarity index 77%
rename from skin/src/main/java/org/researchstack/skin/model/ConsentSectionModel.java
rename to backbone/src/main/java/org/researchstack/backbone/model/ConsentSectionModel.java
index 092a091b7..18268050b 100644
--- a/skin/src/main/java/org/researchstack/skin/model/ConsentSectionModel.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSectionModel.java
@@ -1,12 +1,10 @@
-package org.researchstack.skin.model;
+package org.researchstack.backbone.model;
import com.google.gson.annotations.SerializedName;
-import org.researchstack.backbone.model.ConsentSection;
-import org.researchstack.backbone.model.DocumentProperties;
-
import java.util.List;
+@Deprecated // No longer needed with new OnboardingManager
public class ConsentSectionModel {
@SerializedName("documentProperties")
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignature.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignature.java
index 1a27e7ea3..63f692a0b 100644
--- a/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignature.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignature.java
@@ -1,11 +1,11 @@
package org.researchstack.backbone.model;
-import android.support.annotation.NonNull;
+import androidx.annotation.NonNull;
import java.io.Serializable;
import java.util.UUID;
-public class ConsentSignature implements Serializable, Cloneable {
+public class ConsentSignature implements Serializable {
/**
* A Boolean value indicating whether the user needs to enter their name during consent review.
@@ -68,6 +68,7 @@ public class ConsentSignature implements Serializable, Cloneable {
*/
private String signatureDateFormatString;
+ /* Default identifier for serilization/deserialization */
public ConsentSignature() {
this.requiresName = true;
this.requiresSignatureImage = true;
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignatureBody.java b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignatureBody.java
new file mode 100644
index 000000000..9fca8f15b
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ConsentSignatureBody.java
@@ -0,0 +1,50 @@
+package org.researchstack.backbone.model;
+
+import java.util.Date;
+
+public class ConsentSignatureBody {
+
+ public ConsentSignatureBody() {
+ // Default constructor
+ }
+
+ /**
+ * The identifier for the study under which the user is signing in
+ */
+ public String study;
+
+ /**
+ * User's name
+ */
+ public String name;
+
+ /**
+ * User's birthdate
+ */
+ public Date birthdate;
+
+ /**
+ * User's signature image data
+ */
+ public String imageData;
+
+ /**
+ * User's signature image mime type
+ */
+ public String imageMimeType;
+
+ /**
+ * User's sharing scope choice
+ */
+ public String scope;
+
+ public ConsentSignatureBody(String study, String name, Date birthdate, String imageData,
+ String imageMimeType, String scope) {
+ this.study = study;
+ this.name = name;
+ this.birthdate = birthdate;
+ this.imageData = imageData;
+ this.imageMimeType = imageMimeType;
+ this.scope = scope;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/DocumentProperties.java b/backbone/src/main/java/org/researchstack/backbone/model/DocumentProperties.java
index 45265aa63..ad4e66a44 100644
--- a/backbone/src/main/java/org/researchstack/backbone/model/DocumentProperties.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/DocumentProperties.java
@@ -2,7 +2,9 @@
import com.google.gson.annotations.SerializedName;
-public class DocumentProperties {
+import java.io.Serializable;
+
+public class DocumentProperties implements Serializable {
@SerializedName("htmlDocument")
private String htmlDocument;
@@ -19,6 +21,11 @@ public class DocumentProperties {
private boolean requiresName;
private boolean requiresBirthdate;
+ /* Default identifier for serilization/deserialization */
+ DocumentProperties() {
+ super();
+ }
+
public String getHtmlDocument() {
return htmlDocument;
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/InclusionCriteriaModel.java b/backbone/src/main/java/org/researchstack/backbone/model/InclusionCriteriaModel.java
new file mode 100644
index 000000000..a5dbb725b
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/InclusionCriteriaModel.java
@@ -0,0 +1,82 @@
+package org.researchstack.backbone.model;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.util.List;
+
+@Deprecated // No longer needed with new OnboardingManager
+public class InclusionCriteriaModel {
+
+ public static final String INELIGIBLE_INSTRUCTION_IDENTIFIER = "ineligibleInstruction";
+ public static final String ELIGIBLE_INSTRUCTION_IDENTIFIER = "eligibleInstruction";
+ @SerializedName("steps")
+ public List steps;
+
+
+ public static class Step
+ {
+ @SerializedName("identifier")
+ public String identifier;
+
+ @SerializedName("type")
+ public StepType type;
+
+ @SerializedName("text")
+ public String text;
+
+ @SerializedName("detailText")
+ public String detailText;
+
+ @SerializedName("image")
+ public String image;
+
+ @SerializedName("nextIdentifier")
+ public String nextIdentifier;
+
+ @SerializedName("skipIdentifier")
+ public String skipIdentifier;
+
+ @SerializedName("skipIfPassed")
+ public boolean skipIfPassed;
+
+ @SerializedName("items")
+ public List- items;
+
+ }
+
+ public enum StepType {
+
+ @SerializedName("instruction")
+ INSTRUCTION("instruction"),
+ @SerializedName("compound")
+ COMPOUND("compound"),
+ @SerializedName("toggle")
+ TOGGLE("toggle"),
+ @SerializedName("share")
+ SHARE("share");
+
+ StepType(String type) {
+ type = type;
+ }
+
+ String type;
+ public String getType() {
+ return type;
+ }
+ }
+
+ public static class Item
+ {
+ @SerializedName("identifier")
+ public String identifier;
+
+ @SerializedName("type")
+ public String type;
+
+ @SerializedName("text")
+ public String text;
+
+ @SerializedName("expectedAnswer")
+ public boolean expectedAnswer;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/ProfileInfoOption.java b/backbone/src/main/java/org/researchstack/backbone/model/ProfileInfoOption.java
new file mode 100644
index 000000000..0710baf2a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/ProfileInfoOption.java
@@ -0,0 +1,73 @@
+package org.researchstack.backbone.model;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ *
+ * Used by several differnt Step types to designate re-usable QuestionStep types
+ * that collect user profile info
+ */
+
+public enum ProfileInfoOption {
+ @SerializedName("email")
+ EMAIL("email"),
+ @SerializedName("password")
+ PASSWORD("password"),
+ @SerializedName("externalID")
+ EXTERNAL_ID("externalID"),
+ @SerializedName("name")
+ NAME("name"),
+ @SerializedName("birthdate")
+ BIRTHDATE("birthdate"),
+ @SerializedName("gender")
+ GENDER("gender"),
+ @SerializedName("bloodType")
+ BLOOD_TYPE("bloodType"),
+ @SerializedName("fitzpatrickSkinType")
+ FITZPATRICK_SKIN_TYPE("fitzpatrickSkinType"),
+ @SerializedName("wheelchairUse")
+ WHEEL_CHAIR_USE("wheelchairUse"),
+ @SerializedName("height")
+ HEIGHT("height"),
+ @SerializedName("weight")
+ WEIGHT("weight"),
+ @SerializedName("wakeTime")
+ WAKE_TIME("wakeTime"),
+ @SerializedName("sleepTime")
+ SLEEP_TIME("sleepTime");
+
+ static Gson gson;
+
+ private final String identifier;
+
+ ProfileInfoOption(String identifier) {
+ this.identifier = identifier;
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+
+ public static List toProfileInfoOptions(List identifiers) {
+ List options = new ArrayList<>();
+ for(String identifier : identifiers) {
+ ProfileInfoOption option = toProfileInfoOption(identifier);
+ if (option != null) {
+ options.add(option);
+ }
+ }
+ return options;
+ }
+
+ public static ProfileInfoOption toProfileInfoOption(String identifier) {
+ if (gson == null) {
+ gson = new Gson();
+ }
+ return gson.fromJson(identifier, ProfileInfoOption.class);
+ }
+}
diff --git a/skin/src/main/java/org/researchstack/skin/model/SchedulesAndTasksModel.java b/backbone/src/main/java/org/researchstack/backbone/model/SchedulesAndTasksModel.java
similarity index 55%
rename from skin/src/main/java/org/researchstack/skin/model/SchedulesAndTasksModel.java
rename to backbone/src/main/java/org/researchstack/backbone/model/SchedulesAndTasksModel.java
index eed0eb6e9..cc1b52b20 100644
--- a/skin/src/main/java/org/researchstack/skin/model/SchedulesAndTasksModel.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/SchedulesAndTasksModel.java
@@ -1,8 +1,11 @@
-package org.researchstack.skin.model;
+package org.researchstack.backbone.model;
import com.google.gson.annotations.SerializedName;
+import java.io.Serializable;
+import java.util.Date;
import java.util.List;
+import java.util.Map;
public class SchedulesAndTasksModel {
public List schedules;
@@ -11,6 +14,8 @@ public static class ScheduleModel {
public String scheduleType;
public String delay;
public String scheduleString;
+ public Date scheduledOn;
+ public Date expiresOn;
public List tasks;
}
@@ -19,7 +24,18 @@ public static class TaskScheduleModel {
public String taskID;
public String taskFileName;
public String taskClassName;
+ public boolean taskIsOptional;
+ public String taskType;
+ public Date taskFinishedOn;
+ /**
+ * The GUID can distinguish between different instances of models with the same taskID
+ */
+ public String taskGUID;
+
+ /**
+ * The time it takes to complete the task
+ */
@SerializedName("taskCompletionTimeString")
public String taskCompletionTime;
}
diff --git a/skin/src/main/java/org/researchstack/skin/model/SectionModel.java b/backbone/src/main/java/org/researchstack/backbone/model/SectionModel.java
similarity index 83%
rename from skin/src/main/java/org/researchstack/skin/model/SectionModel.java
rename to backbone/src/main/java/org/researchstack/backbone/model/SectionModel.java
index 7440737d6..33b152607 100644
--- a/skin/src/main/java/org/researchstack/skin/model/SectionModel.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/SectionModel.java
@@ -1,14 +1,28 @@
-package org.researchstack.skin.model;
+package org.researchstack.backbone.model;
import com.google.gson.annotations.SerializedName;
import java.util.List;
public class SectionModel {
+ public static final String SHARE_TYPE_DETAILS = "share";
+
+ @SerializedName("logo_name")
+ private String logoName;
+
+ private String title;
@SerializedName("items")
private List sections;
+ public String getLogoName() {
+ return logoName;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
public List getSections() {
return sections;
}
diff --git a/skin/src/main/java/org/researchstack/skin/model/StudyOverviewModel.java b/backbone/src/main/java/org/researchstack/backbone/model/StudyOverviewModel.java
similarity index 97%
rename from skin/src/main/java/org/researchstack/skin/model/StudyOverviewModel.java
rename to backbone/src/main/java/org/researchstack/backbone/model/StudyOverviewModel.java
index 854346d13..1cfe8f609 100644
--- a/skin/src/main/java/org/researchstack/skin/model/StudyOverviewModel.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/StudyOverviewModel.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin.model;
+package org.researchstack.backbone.model;
import com.google.gson.annotations.SerializedName;
diff --git a/skin/src/main/java/org/researchstack/skin/model/TaskModel.java b/backbone/src/main/java/org/researchstack/backbone/model/TaskModel.java
similarity index 98%
rename from skin/src/main/java/org/researchstack/skin/model/TaskModel.java
rename to backbone/src/main/java/org/researchstack/backbone/model/TaskModel.java
index 59ea38a6b..412017ced 100644
--- a/skin/src/main/java/org/researchstack/skin/model/TaskModel.java
+++ b/backbone/src/main/java/org/researchstack/backbone/model/TaskModel.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin.model;
+package org.researchstack.backbone.model;
import com.google.gson.annotations.SerializedName;
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/User.java b/backbone/src/main/java/org/researchstack/backbone/model/User.java
new file mode 100644
index 000000000..5e7957e71
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/User.java
@@ -0,0 +1,96 @@
+package org.researchstack.backbone.model;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/*
+ Created by bradleymcdermott on 10/22/15.
+ */
+public class User implements Serializable
+{
+ private String name;
+
+ private String email;
+
+ private Date birthDate;
+
+ private UserHealth userHealth;
+
+ /**
+ * See description above DataSharingScope inner enum below
+ */
+ private DataSharingScope dataSharingScope;
+
+ /** Default constructor for Serializable */
+ public User()
+ {
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+ public String getEmail()
+ {
+ return email;
+ }
+
+ public void setEmail(String email)
+ {
+ this.email = email;
+ }
+
+ public Date getBirthDate()
+ {
+ return birthDate;
+ }
+
+ public void setBirthDate(Date birthDate)
+ {
+ this.birthDate = birthDate;
+ }
+
+ public DataSharingScope getDataSharingScope() {
+ return dataSharingScope;
+ }
+
+ public void setDataSharingScope(DataSharingScope dataSharingScope) {
+ this.dataSharingScope = dataSharingScope;
+ }
+
+ public UserHealth getUserHealth() {
+ return userHealth;
+ }
+
+ public void setUserHealth(UserHealth userHealth) {
+ this.userHealth = userHealth;
+ }
+
+ /*!
+ * DataSharingScope is an enumeration of the choices for the scope of sharing collected data.
+ * NONE - The user has not consented to sharing their data.
+ * STUDY - The user has consented only to sharing their de-identified data with the sponsors and partners of the current research study.
+ * ALL - The user has consented to sharing their de-identified data for current and future research, which may or may not involve the same institutions or investigators.
+ */
+ public enum DataSharingScope {
+ NONE("no_sharing"),
+ STUDY("sponsors_and_partners"),
+ ALL("all_qualified_researchers");
+
+ private String identifier;
+
+ DataSharingScope(String identifier) {
+ this.identifier = identifier;
+ }
+
+ public String getIdentifier() {
+ return identifier;
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/UserHealth.java b/backbone/src/main/java/org/researchstack/backbone/model/UserHealth.java
new file mode 100644
index 000000000..31f6949f0
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/UserHealth.java
@@ -0,0 +1,105 @@
+package org.researchstack.backbone.model;
+
+import android.content.Context;
+
+import org.researchstack.backbone.R;
+
+import java.io.Serializable;
+
+/**
+ * Created by TheMDP on 2/15/17.
+ *
+ * This should mimic the HealthKit values store on iOS
+ */
+
+public class UserHealth implements Serializable {
+
+ public static final float NO_VALUE = -1.0f;
+
+ /**
+ * Weight in lbs
+ */
+ private float weight = NO_VALUE;
+
+ /**
+ * Height in inches
+ */
+ private float height = NO_VALUE;
+
+ /**
+ * Gender of the user
+ */
+ private Gender gender = Gender.NOT_SET;
+
+ /** Default constructor for Serializable */
+ protected UserHealth() {}
+
+ /**
+ * @return valid weight measurement if it has been entered by the user
+ * it will return -1 if no valid weight is available
+ */
+ public float getWeight() {
+ return weight;
+ }
+
+ /**
+ * @return true if a valid weight measurement has been entered by the user, false otherwise
+ */
+ public boolean hasWeight() {
+ return !(weight < 0);
+ }
+
+ public void setWeight(float weight) {
+ this.weight = weight;
+ }
+
+ /**
+ * @return valid height measurement if it has been entered by the user
+ * it will return -1 if no valid height is available
+ */
+ public float getHeight() {
+ return height;
+ }
+
+ /**
+ * @return true if a valid height measurement has been entered by the user, false otherwise
+ */
+ public boolean hasHeight() {
+ return !(height < 0);
+ }
+
+ public void setHeight(float height) {
+ this.height = height;
+ }
+
+ /**
+ * @return gender of the user, defaults to NOT_SET
+ */
+ public Gender getGender() {
+ return gender;
+ }
+
+ public void setGender(Gender gender) {
+ this.gender = gender;
+ }
+
+ public enum Gender {
+ MALE,
+ FEMALE,
+ OTHER,
+ NOT_SET;
+
+ public String localizedTitle(Context context) {
+ switch (this) {
+ case MALE:
+ return context.getString(R.string.rsb_GENDER_MALE);
+ case FEMALE:
+ return context.getString(R.string.rsb_GENDER_FEMALE);
+ case OTHER:
+ return context.getString(R.string.rsb_GENDER_OTHER);
+ default:
+ return NOT_SET.toString(); // no need to localize
+ }
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/ActiveStepSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/ActiveStepSurveyItem.java
new file mode 100644
index 000000000..eb42ecfdf
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/ActiveStepSurveyItem.java
@@ -0,0 +1,79 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.util.Map;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ */
+
+public class ActiveStepSurveyItem extends SurveyItem {
+
+ @SerializedName("stepSpokenInstruction")
+ private String stepSpokenInstruction;
+
+ @SerializedName("stepFinishedSpokenInstruction")
+ private String stepFinishedSpokenInstruction;
+
+ @SerializedName(value="stepDuration", alternate={"duration"})
+ private int stepDuration;
+
+ /**
+ * A map of <"time_in_seconds_to_speak", "what_to_speak">
+ */
+ @SerializedName("spokenInstructions")
+ private Map spokenInstructionMap;
+
+ /**
+ * A string representation of a raw file resource,
+ * it will play on start() of ActiveStepLayout
+ */
+ @SerializedName("soundRes")
+ private String soundRes;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public ActiveStepSurveyItem() {
+ super();
+ }
+
+ public String getStepSpokenInstruction() {
+ return stepSpokenInstruction;
+ }
+
+ public void setStepSpokenInstruction(String stepSpokenInstruction) {
+ this.stepSpokenInstruction = stepSpokenInstruction;
+ }
+
+ public String getStepFinishedSpokenInstruction() {
+ return stepFinishedSpokenInstruction;
+ }
+
+ public void setStepFinishedSpokenInstruction(String stepFinishedSpokenInstruction) {
+ this.stepFinishedSpokenInstruction = stepFinishedSpokenInstruction;
+ }
+
+ public int getStepDuration() {
+ return stepDuration;
+ }
+
+ public void setStepDuration(int stepDuration) {
+ this.stepDuration = stepDuration;
+ }
+
+ public Map getSpokenInstructionMap() {
+ return spokenInstructionMap;
+ }
+
+ public void setSpokenInstructionMap(Map spokenInstructions) {
+ spokenInstructionMap = spokenInstructions;
+ }
+
+ public String getSoundRes() {
+ return soundRes;
+ }
+
+ public void setSoundRes(String soundRes) {
+ this.soundRes = soundRes;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/BaseSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/BaseSurveyItem.java
new file mode 100644
index 000000000..81ea99d1b
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/BaseSurveyItem.java
@@ -0,0 +1,12 @@
+package org.researchstack.backbone.model.survey;
+
+/**
+ * Created by TheMDP on 1/2/17.
+ */
+
+public class BaseSurveyItem extends SurveyItem {
+ /* Default constructor needed for serilization/deserialization of object */
+ BaseSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/BooleanQuestionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/BooleanQuestionSurveyItem.java
new file mode 100644
index 000000000..768031906
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/BooleanQuestionSurveyItem.java
@@ -0,0 +1,14 @@
+package org.researchstack.backbone.model.survey;
+
+import org.researchstack.backbone.model.Choice;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class BooleanQuestionSurveyItem extends QuestionSurveyItem> {
+ /* Default constructor needed for serialization/deserialization of object */
+ public BooleanQuestionSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/ChoiceQuestionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/ChoiceQuestionSurveyItem.java
new file mode 100644
index 000000000..5ea73892a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/ChoiceQuestionSurveyItem.java
@@ -0,0 +1,14 @@
+package org.researchstack.backbone.model.survey;
+
+import org.researchstack.backbone.model.Choice;
+
+/**
+ * Created by TheMDP on 1/2/17.
+ */
+
+public class ChoiceQuestionSurveyItem extends QuestionSurveyItem {
+ /* Default constructor needed for serilization/deserialization of object */
+ ChoiceQuestionSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/ConsentReviewSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/ConsentReviewSurveyItem.java
new file mode 100644
index 000000000..c91760504
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/ConsentReviewSurveyItem.java
@@ -0,0 +1,12 @@
+package org.researchstack.backbone.model.survey;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class ConsentReviewSurveyItem extends ProfileSurveyItem {
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentReviewSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/ConsentSharingOptionsSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/ConsentSharingOptionsSurveyItem.java
new file mode 100644
index 000000000..d234db000
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/ConsentSharingOptionsSurveyItem.java
@@ -0,0 +1,23 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+import org.researchstack.backbone.model.Choice;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class ConsentSharingOptionsSurveyItem extends SurveyItem> {
+ @SerializedName("investigatorShortDescription")
+ public String investigatorShortDescription;
+ @SerializedName("investigatorLongDescription")
+ public String investigatorLongDescription;
+ @SerializedName("learnMoreHTMLContentURL")
+ public String learnMoreHTMLContentURL;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentSharingOptionsSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/DateRangeSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/DateRangeSurveyItem.java
new file mode 100644
index 000000000..b9946b034
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/DateRangeSurveyItem.java
@@ -0,0 +1,14 @@
+package org.researchstack.backbone.model.survey;
+
+import java.util.Date;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class DateRangeSurveyItem extends RangeSurveyItem {
+ /* Default constructor needed for serilization/deserialization of object */
+ DateRangeSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/FloatRangeSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/FloatRangeSurveyItem.java
new file mode 100644
index 000000000..ead88d96d
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/FloatRangeSurveyItem.java
@@ -0,0 +1,12 @@
+package org.researchstack.backbone.model.survey;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class FloatRangeSurveyItem extends RangeSurveyItem {
+ /* Default constructor needed for serilization/deserialization of object */
+ FloatRangeSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/FormSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/FormSurveyItem.java
new file mode 100644
index 000000000..9f49d168b
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/FormSurveyItem.java
@@ -0,0 +1,31 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class FormSurveyItem extends QuestionSurveyItem {
+
+ /**
+ * When the expectedAnswer is this String, skipToStepIdentifier will be invoked
+ * If the StepResult does not contain any valid results, aka "Skip" button was clicked
+ */
+ public static final String SKIP_BUTTON_TAPPED_ACTION_IDENTIFIER = "whenSkipButtonClicked";
+
+ @SerializedName("skipTitle")
+ public String skipTitle;
+
+ /**
+ * If true, the first question body layout with an edittext will receive focus on load
+ * default is false and nothing will occur
+ */
+ @SerializedName("autoFocusFirst")
+ public Boolean autoFocusFirstEditText;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public FormSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/InstructionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/InstructionSurveyItem.java
new file mode 100644
index 000000000..31b672fb8
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/InstructionSurveyItem.java
@@ -0,0 +1,53 @@
+package org.researchstack.backbone.model.survey;
+
+import android.widget.ImageView;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ */
+
+public class InstructionSurveyItem extends SurveyItem {
+
+ @SerializedName("detailText")
+ public String detailText;
+
+ @SerializedName("image")
+ public String image;
+
+ @SerializedName("isImageAnimated")
+ public boolean isImageAnimated;
+
+ @SerializedName("animationRepeatDuration")
+ public long animationRepeatDuration;
+
+ @SerializedName("iconImage")
+ public String iconImage;
+
+ /**
+ * Pointer to the next step to show after this one. If nil, then the next step
+ * is determined by the navigation rules setup by NavigableOrderedTask.
+ */
+ @SerializedName("nextIdentifier")
+ public String nextIdentifier;
+
+ @SerializedName("learnMoreHTMLContentURL")
+ public String learnMoreHTMLContentURL;
+
+ /**
+ * Defaults to centerInside, but can be any scale type in the form of
+ * CENTER, CENTER_CROP, etc...
+ */
+ @SerializedName("scaleType")
+ public ImageView.ScaleType scaleType;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public InstructionSurveyItem() {
+ super();
+ }
+
+ public boolean usesNavigation() {
+ return nextIdentifier != null;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/IntegerRangeSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/IntegerRangeSurveyItem.java
new file mode 100644
index 000000000..ce87d29f0
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/IntegerRangeSurveyItem.java
@@ -0,0 +1,18 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class IntegerRangeSurveyItem extends RangeSurveyItem {
+
+ @SerializedName("maxLength")
+ public Integer maxLength;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ IntegerRangeSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/ProfileSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/ProfileSurveyItem.java
new file mode 100644
index 000000000..19c6189ce
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/ProfileSurveyItem.java
@@ -0,0 +1,12 @@
+package org.researchstack.backbone.model.survey;
+
+/**
+ * Created by TheMDP on 1/2/17.
+ */
+
+public class ProfileSurveyItem extends SurveyItem {
+ /* Default constructor needed for serilization/deserialization of object */
+ ProfileSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/QuestionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/QuestionSurveyItem.java
new file mode 100644
index 000000000..578011ae1
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/QuestionSurveyItem.java
@@ -0,0 +1,55 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.io.Serializable;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ */
+
+public class QuestionSurveyItem extends SurveyItem {
+
+ @SerializedName("questionStyle")
+ public boolean questionStyle;
+ @SerializedName("placeholderText")
+ public String placeholderText;
+ @SerializedName("optional")
+ public boolean optional;
+ @SerializedName("range")
+ public RangeSurveyItem range;
+
+ @SerializedName(value="expectedAnswer", alternate={"matchingAnswer"})
+ public Object expectedAnswer;
+
+ @SerializedName("skipIdentifier")
+ public String skipIdentifier;
+
+ @SerializedName("skipIfPassed")
+ public boolean skipIfPassed;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public QuestionSurveyItem() {
+ super();
+ }
+
+ public boolean isValidQuestionItem() {
+ return identifier != null && type.isQuestionSubtype();
+ }
+
+ public boolean isCompoundStep() {
+ return type == SurveyItemType.QUESTION_FORM;
+ }
+
+ /**
+ * @return false by default, true if this question survey item
+ * can be used to create a QuestionStep that will implement
+ * an interface in NavigableOrderedTask
+ */
+ public boolean usesNavigation() {
+ if (skipIdentifier != null || expectedAnswer != null) {
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/RangeSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/RangeSurveyItem.java
new file mode 100644
index 000000000..f64b2660a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/RangeSurveyItem.java
@@ -0,0 +1,23 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ */
+
+public class RangeSurveyItem extends QuestionSurveyItem {
+ @SerializedName("min")
+ public T min;
+ @SerializedName("max")
+ public T max;
+ @SerializedName("defaultValue")
+ public T defaultValue;
+ @SerializedName("unit")
+ public String unit;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ RangeSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/ScaleQuestionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/ScaleQuestionSurveyItem.java
new file mode 100644
index 000000000..ab370ef93
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/ScaleQuestionSurveyItem.java
@@ -0,0 +1,19 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ *
+ * Represents a slider that can go from integer min to integer high, at a certain step value
+ */
+
+public class ScaleQuestionSurveyItem extends IntegerRangeSurveyItem {
+ @SerializedName("step")
+ public int step;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ ScaleQuestionSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/SubtaskQuestionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/SubtaskQuestionSurveyItem.java
new file mode 100644
index 000000000..a253d89b5
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/SubtaskQuestionSurveyItem.java
@@ -0,0 +1,13 @@
+package org.researchstack.backbone.model.survey;
+
+/**
+ * Created by TheMDP on 1/2/17.
+ */
+
+public class SubtaskQuestionSurveyItem extends QuestionSurveyItem {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ SubtaskQuestionSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItem.java
new file mode 100644
index 000000000..bde5c9b47
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItem.java
@@ -0,0 +1,126 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.io.Serializable;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ *
+ * Generic Type "T" of survey item must also implement Serializable,
+ * Otherwise you will see a runtime exception
+ */
+
+public class SurveyItem implements Serializable {
+
+ public static final String IDENTIFIER_GSON = "identifier";
+ @SerializedName(IDENTIFIER_GSON)
+ public String identifier;
+
+ public static final String TYPE_GSON = "type";
+ public static final String TYPE_GSON_2 = "dataType";
+ @SerializedName(value=TYPE_GSON, alternate={TYPE_GSON_2})
+ public SurveyItemType type;
+
+ @SerializedName(value="title", alternate={"prompt"})
+ public String title;
+
+ @SerializedName("text")
+ public String text;
+
+ @SerializedName("footnote")
+ public String footnote;
+
+ @SerializedName(value="items", alternate={"choices", "inputFields"})
+ public List items;
+
+ /**
+ * This holds the original Json Object that was used to create this object
+ * Only is set if this object was created with the SurveyItemAdapter
+ */
+ private transient String rawJson;
+
+ /**
+ * This is simply used to keep track of state for the SurveyItemFactory, and will not be serialized
+ */
+ private String customSurveyItemType;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ protected SurveyItem() {
+ super();
+ }
+
+ public static class SurveyItemTypeComparator implements Comparator {
+
+ @Override
+ public int compare(SurveyItemType lhs, SurveyItemType rhs) {
+
+ return 0;
+ }
+ }
+
+ public String getTypeIdentifier() {
+ if (isCustomStep()) {
+ return customSurveyItemType;
+ }
+ return type.getValue();
+ }
+
+ public String getIdentifier() {
+ if (identifier == null) {
+ return getTypeIdentifier();
+ }
+ return identifier;
+ }
+
+ @Override
+ public int hashCode() {
+ if (identifier == null) {
+ return super.hashCode();
+ }
+ return identifier.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof SurveyItem)) {
+ return false;
+ }
+ if (obj == this) {
+ return true;
+ }
+
+ SurveyItem rhs = (SurveyItem) obj;
+
+ if (identifier == null || rhs.identifier == null) {
+ return false;
+ }
+
+ return identifier.equals(rhs.identifier);
+ }
+
+ protected void setCustomTypeValue(String value) {
+ customSurveyItemType = value;
+ }
+
+ public String getCustomTypeValue() {
+ return customSurveyItemType;
+ }
+
+ public void setRawJson(String json) {
+ this.rawJson = json;
+ }
+
+ public String getRawJson() {
+ return rawJson;
+ }
+
+ /**
+ * @return true if the survey item type is custom, false otherwise
+ */
+ public boolean isCustomStep() {
+ return customSurveyItemType != null;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItemAdapter.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItemAdapter.java
new file mode 100644
index 000000000..23ef4f6a1
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItemAdapter.java
@@ -0,0 +1,155 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+
+import java.lang.reflect.Type;
+
+/**
+ * Created by TheMDP on 1/2/17.
+ *
+ * This class is the deserializer for SurveyItem classes
+ * It looks at the "type" field, attempts to map it to this library's pre-defined types
+ * and if it does not find it, creates a custom survey item
+ * the class of the custom survey item can easily be controlled by overriding this
+ * adapter, and overriding the method getCustomClass
+ *
+ * To go even further and change the mapping of the custom survey item to a custom step,
+ * you should override SurveyFactory's method public Step createCustomStep(SurveyItem item)
+ * which is the go to for converting a survey item to a step
+ */
+
+public class SurveyItemAdapter implements JsonDeserializer {
+
+ @Override
+ public SurveyItem deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+ JsonObject jsonObject = json.getAsJsonObject();
+
+ JsonElement typeJson = jsonObject.get(SurveyItem.TYPE_GSON);
+ if (typeJson == null) {
+ typeJson = jsonObject.get(SurveyItem.TYPE_GSON_2);
+ }
+ SurveyItemType surveyItemType = context.deserialize(typeJson, SurveyItemType.class);
+
+ // This was a custom survey item type
+ // For instance, "reconsent.instruction" is a subtask consent type
+ // That will be dealt with by a custom ConsentDocumentSurveyFactory
+ String customTypeString = null;
+ if (surveyItemType == null) {
+ surveyItemType = SurveyItemType.CUSTOM;
+ // Some JSON can be malformed and there is no type, only identifier
+ // In that case let's try and still parse it by setting the type as the identifier;
+ if (typeJson != null) {
+ customTypeString = typeJson.getAsString();
+ } else { // use "identifier" string
+ customTypeString = jsonObject.get(SurveyItem.IDENTIFIER_GSON).getAsString();
+ }
+ }
+
+ SurveyItem item = null;
+
+ switch (surveyItemType) {
+ case INSTRUCTION:
+ case INSTRUCTION_COMPLETION:
+ item = context.deserialize(json, InstructionSurveyItem.class);
+ break;
+ case SUBTASK:
+ item = context.deserialize(json, SubtaskQuestionSurveyItem.class);
+ break;
+ case QUESTION_FORM:
+ item = context.deserialize(json, FormSurveyItem.class);
+ break;
+ case QUESTION_BOOLEAN:
+ item = context.deserialize(json, BooleanQuestionSurveyItem.class);
+ break;
+ case QUESTION_DECIMAL:
+ item = context.deserialize(json, FloatRangeSurveyItem.class);
+ break;
+ case QUESTION_INTEGER:
+ item = context.deserialize(json, IntegerRangeSurveyItem.class);
+ break;
+ case QUESTION_DURATION:
+ break;
+ case QUESTION_SCALE:
+ item = context.deserialize(json, ScaleQuestionSurveyItem.class);
+ break;
+ case QUESTION_TEXT:
+ case QUESTION_EMAIL:
+ item = context.deserialize(json, TextfieldSurveyItem.class);
+ break;
+ case QUESTION_DATE:
+ case QUESTION_DATE_TIME:
+ case QUESTION_TIME:
+ item = context.deserialize(json, DateRangeSurveyItem.class);
+ break;
+ case QUESTION_MULTIPLE_CHOICE:
+ case QUESTION_SINGLE_CHOICE:
+ item = context.deserialize(json, ChoiceQuestionSurveyItem.class);
+ break;
+ case QUESTION_TIMING_RANGE:
+ item = context.deserialize(json, TimingRangeQuestionSurveyItem.class);
+ break;
+ case CONSENT_SHARING_OPTIONS:
+ item = context.deserialize(json, ConsentSharingOptionsSurveyItem.class);
+ break;
+ case CONSENT_REVIEW:
+ item = context.deserialize(json, ConsentReviewSurveyItem.class);
+ break;
+ case CONSENT_VISUAL:
+ break;
+ case RE_CONSENT:
+ item = context.deserialize(json, InstructionSurveyItem.class);
+ break;
+ case ACCOUNT_REGISTRATION:
+ case ACCOUNT_LOGIN:
+ case ACCOUNT_LOGIN_VIA_EMAIL:
+ case ACCOUNT_PROFILE:
+ case ACCOUNT_EXTERNAL_ID:
+ item = context.deserialize(json, ProfileSurveyItem.class);
+ break;
+ case ACCOUNT_COMPLETION:
+ case ACCOUNT_EMAIL_VERIFICATION:
+ item = context.deserialize(json, InstructionSurveyItem.class);
+ break;
+ case ACCOUNT_DATA_GROUPS:
+ case ACCOUNT_PERMISSIONS:
+ case PASSCODE:
+ break;
+ case SHARE_THE_APP:
+ item = context.deserialize(json, InstructionSurveyItem.class);
+ break;
+ case ACTIVE_STEP:
+ item = context.deserialize(json, ActiveStepSurveyItem.class);
+ break;
+ case CUSTOM:
+ item = context.deserialize(json, getCustomClass(customTypeString, json));
+ item.type = surveyItemType; // need to set CUSTOM type for surveyItem, since it is a special case
+ item.setCustomTypeValue(customTypeString);
+ break;
+ }
+
+ if (item == null) {
+ item = context.deserialize(json, BaseSurveyItem.class);
+ item.type = surveyItemType;
+ }
+
+ item.setRawJson(json.toString());
+
+ return item;
+ }
+
+ /**
+ * This can be overridden by subclasses to provide custom survey item deserialization
+ * the default deserialization is always an instruction survey item
+ * @param customType used to map to different types of survey items
+ * @param json if customType is not enough, you can use the JsonElement to determine how to parse
+ * it's contents by peeking at it's variables
+ * @return type of survey item to create from the custom class
+ */
+ public Class extends SurveyItem> getCustomClass(String customType, JsonElement json) {
+ return BaseSurveyItem.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItemType.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItemType.java
new file mode 100644
index 000000000..ccaaa9e7a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/SurveyItemType.java
@@ -0,0 +1,118 @@
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+import org.researchstack.backbone.model.survey.factory.ConsentDocumentFactory;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ */
+
+public enum SurveyItemType {
+
+ CUSTOM(null),
+ // Subtask subtypes
+ @SerializedName("subtask")
+ SUBTASK ("subtask"), // SubtaskStep
+ // Instruction subtypes
+ @SerializedName("instruction")
+ INSTRUCTION ("instruction"), // InstructionStep
+ @SerializedName("completion")
+ INSTRUCTION_COMPLETION ("completion"), // CompletionStep
+ @SerializedName(value="compound", alternate={"form", "toggle"})
+ QUESTION_FORM("compound"), // QuestionSteps > 1
+ @SerializedName("boolean")
+ QUESTION_BOOLEAN ("boolean"), // ORKBooleanAnswerFormat
+ @SerializedName("singleChoiceText")
+ QUESTION_SINGLE_CHOICE ("singleChoiceText"), // ORKTextChoiceAnswerFormat of style SingleChoiceTextQuestion
+ @SerializedName("multipleChoiceText")
+ QUESTION_MULTIPLE_CHOICE ("multipleChoiceText"), // ORKTextChoiceAnswerFormat of style MultipleChoiceTextQuestion
+ @SerializedName("textfield")
+ QUESTION_TEXT ("textfield"), // ORKTextAnswerFormat
+ @SerializedName("emailTextField")
+ QUESTION_EMAIL ("emailTextField"), // EmailAnswerFormat
+ @SerializedName("datePicker")
+ QUESTION_DATE ("datePicker"), // ORKDateAnswerFormat of style Date
+ @SerializedName("timeAndDatePicker")
+ QUESTION_DATE_TIME ("timeAndDatePicker"), // ORKDateAnswerFormat of style DateTime
+ @SerializedName("timePicker")
+ QUESTION_TIME ("timePicker"), // ORKTimeOfDayAnswerFormat
+ @SerializedName("timeInterval")
+ QUESTION_DURATION ("timeInterval"), // ORKTimeIntervalAnswerFormat
+ @SerializedName(value="numericInteger", alternate={"integer"})
+ QUESTION_INTEGER ("numericInteger"), // ORKNumericAnswerFormat of style Integer
+ @SerializedName("numericDecimal")
+ QUESTION_DECIMAL ("numericDecimal"), // ORKNumericAnswerFormat of style Decimal
+ @SerializedName("scaleInteger")
+ QUESTION_SCALE ("scaleInteger"), // ORKScaleAnswerFormat
+ @SerializedName("timingRange")
+ QUESTION_TIMING_RANGE ("timingRange"), // Timing Range: ORKTextChoiceAnswerFormat of style SingleChoiceTextQuestion
+ // Account subtypes
+ @SerializedName("registration")
+ ACCOUNT_REGISTRATION ("registration" ), // ProfileStep
+ @SerializedName("login")
+ ACCOUNT_LOGIN ("login" ), // LoginStep
+ @SerializedName("loginViaEmail")
+ ACCOUNT_LOGIN_VIA_EMAIL ("loginViaEmail" ), // LoginStep
+ @SerializedName("emailVerification")
+ ACCOUNT_EMAIL_VERIFICATION ("emailVerification" ), // EmailVerificationStep
+ @SerializedName("externalID")
+ ACCOUNT_EXTERNAL_ID ("externalID" ), // ExternalIDStep
+ @SerializedName("permissions")
+ ACCOUNT_PERMISSIONS ("permissions" ), // PermissionsStep
+ @SerializedName("onboardingCompletion")
+ ACCOUNT_COMPLETION ("onboardingCompletion"), // OnboardingCompletionStep
+ @SerializedName("dataGroups")
+ ACCOUNT_DATA_GROUPS ("dataGroups"), // DataGroupsStep
+ @SerializedName("profile")
+ ACCOUNT_PROFILE ("profile"), // ProfileQuestionStep or ProfileFormStep
+ @SerializedName("shareApp")
+ SHARE_THE_APP ("shareApp"), // ShareTheAppStep
+ // Passcode subtypes
+ @SerializedName(value="PASSCODE", alternate={"passcodeType4Digit", "passcodeType6Digit"})
+ PASSCODE ("passcode"), // iOS has 6 digit too, but for now only support 4 digit
+ // Active Step
+ @SerializedName("active")
+ ACTIVE_STEP ("active"), // ActiveStep
+
+
+ // Consent subtypes
+ @SerializedName(ConsentDocumentFactory.CONSENT_SHARING_IDENTIFIER)
+ CONSENT_SHARING_OPTIONS (ConsentDocumentFactory.CONSENT_SHARING_IDENTIFIER), // ConsentSharingStep
+ @SerializedName(ConsentDocumentFactory.CONSENT_REVIEW_IDENTIFIER)
+ CONSENT_REVIEW (ConsentDocumentFactory.CONSENT_REVIEW_IDENTIFIER), // ConsentReviewStep
+ @SerializedName ("consentVisual")
+ CONSENT_VISUAL ("consentVisual"), // VisualConsentStep
+ @SerializedName ("reconsent.instruction")
+ RE_CONSENT ("reconsent.instruction"); // ReconsentInstructionStep
+
+ SurveyItemType(String rawValue) {
+ value = rawValue;
+ }
+
+ private String value;
+ public String getValue() {
+ return value;
+ }
+
+ public boolean isQuestionSubtype() {
+ switch (this) {
+ case QUESTION_FORM:
+ case QUESTION_BOOLEAN:
+ case QUESTION_SINGLE_CHOICE:
+ case QUESTION_MULTIPLE_CHOICE:
+ case QUESTION_TEXT:
+ case QUESTION_DATE:
+ case QUESTION_DATE_TIME:
+ case QUESTION_TIME:
+ case QUESTION_DURATION:
+ case QUESTION_INTEGER:
+ case QUESTION_DECIMAL:
+ case QUESTION_SCALE:
+ case QUESTION_TIMING_RANGE:
+ return true;
+ default:
+ return false;
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/TextfieldSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/TextfieldSurveyItem.java
new file mode 100644
index 000000000..e3d92c322
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/TextfieldSurveyItem.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2017 Sage Bionetworks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.researchstack.backbone.model.survey;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 11/17/17.
+ */
+
+public class TextfieldSurveyItem extends QuestionSurveyItem {
+
+ @SerializedName("inputType")
+ public Integer inputType;
+
+ @SerializedName("validationRegex")
+ public String validationRegex;
+
+ @SerializedName("disabled")
+ public Boolean disabled;
+
+ @SerializedName("isMultipleLines")
+ public Boolean isMultipleLines;
+
+ @SerializedName("maxLength")
+ public Integer maxLength;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public TextfieldSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/TimingRangeQuestionSurveyItem.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/TimingRangeQuestionSurveyItem.java
new file mode 100644
index 000000000..e8592b8e3
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/TimingRangeQuestionSurveyItem.java
@@ -0,0 +1,12 @@
+package org.researchstack.backbone.model.survey;
+
+/**
+ * Created by TheMDP on 3/25/17.
+ */
+
+public class TimingRangeQuestionSurveyItem extends QuestionSurveyItem {
+ /* Default constructor needed for serialization/deserialization of object */
+ public TimingRangeQuestionSurveyItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/factory/ConsentDocumentFactory.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/factory/ConsentDocumentFactory.java
new file mode 100644
index 000000000..c4db492d1
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/factory/ConsentDocumentFactory.java
@@ -0,0 +1,378 @@
+package org.researchstack.backbone.model.survey.factory;
+
+import android.content.Context;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.answerformat.AnswerFormat;
+import org.researchstack.backbone.answerformat.ChoiceAnswerFormat;
+import org.researchstack.backbone.model.Choice;
+import org.researchstack.backbone.model.ConsentDocument;
+import org.researchstack.backbone.model.ConsentSection;
+import org.researchstack.backbone.model.ProfileInfoOption;
+import org.researchstack.backbone.model.survey.ConsentReviewSurveyItem;
+import org.researchstack.backbone.model.survey.ConsentSharingOptionsSurveyItem;
+import org.researchstack.backbone.model.survey.InstructionSurveyItem;
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.onboarding.OnboardingSection;
+import org.researchstack.backbone.onboarding.ReConsentInstructionStep;
+import org.researchstack.backbone.step.ConsentDocumentStep;
+import org.researchstack.backbone.step.ConsentReviewSubstepListStep;
+import org.researchstack.backbone.step.ConsentSharingStep;
+import org.researchstack.backbone.step.ConsentSignatureStep;
+import org.researchstack.backbone.step.ConsentSubtaskStep;
+import org.researchstack.backbone.step.ConsentVisualStep;
+import org.researchstack.backbone.step.ProfileStep;
+import org.researchstack.backbone.step.RegistrationStep;
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.step.SubtaskStep;
+import org.researchstack.backbone.step.NavigationSubtaskStep;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 12/29/16.
+ */
+
+public class ConsentDocumentFactory extends SurveyFactory {
+
+ public static final String CONSENT_SUBTASK_ID = "consent";
+ public static final String CONSENT_SIGNATURE_IDENTIFIER = "consentSignature";
+ public static final String CONSENT_REVIEW_PROFILE_IDENTIFIER = "consentReviewProfile";
+ public static final String CONSENT_SHARING_IDENTIFIER = "consentSharingOptions";
+ public static final String CONSENT_REVIEW_IDENTIFIER = "consentReview";
+
+ /**
+ * A list of steps that is used to track the state of Survey Factory so
+ * They can be used to create different types of Tasks
+ */
+ private List stepList;
+
+ private ConsentDocument consentDocument;
+
+ public ConsentDocumentFactory() {
+ super();
+ stepList = new ArrayList<>();
+ }
+
+ /**
+ * @param document consent document, already de-serialized
+ */
+ public ConsentDocumentFactory(ConsentDocument document) {
+ this();
+ consentDocument = document;
+ }
+
+ /**
+ * @param document consent document, already deserialized
+ * @param customStepCreator override to control step creation from custom survey items
+ */
+ public ConsentDocumentFactory(
+ ConsentDocument document,
+ CustomStepCreator customStepCreator)
+ {
+ this();
+ consentDocument = document;
+ setCustomStepCreator(customStepCreator);
+ }
+
+ @Override
+ public List createSurveySteps(Context context, List surveyItems) {
+ stepList = new ArrayList<>();
+ return super.createSurveySteps(context, surveyItems);
+ }
+
+ /**
+ * This is how we can assess our own types for consent
+ * @param item SurveyItem from JSON
+ * @return valid Step matching the SurveyItem
+ */
+ @Override
+ public Step createSurveyStep(Context context, SurveyItem item, boolean isSubtaskStep) {
+ Step step = null;
+ switch (item.type) {
+ case CONSENT_REVIEW:
+ if (!(item instanceof ConsentReviewSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, CONSENT_REVIEW types must be ConsentReviewSurveyItem");
+ }
+ if (consentDocument == null) {
+ throw new IllegalStateException("Consent document cannot be null!");
+ }
+ step = createConsentReviewSteps(context, (ConsentReviewSurveyItem)item);
+ break;
+ case CONSENT_SHARING_OPTIONS:
+ if (!(item instanceof ConsentSharingOptionsSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, CONSENT_SHARING_OPTIONS types must be ConsentSharingOptionsSurveyItem");
+ }
+ step = createConsentSharingStep(context, (ConsentSharingOptionsSurveyItem)item);
+ break;
+ case CONSENT_VISUAL:
+ if (consentDocument == null) {
+ throw new IllegalStateException("Consent document cannot be null!");
+ }
+ step = createConsentVisualSteps(item, consentDocument.getSections());
+ break;
+ case RE_CONSENT:
+ if (!(item instanceof InstructionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, RE_CONSENT types must be InstructionSurveyItem");
+ }
+ step = createReConsentInstructionStep((InstructionSurveyItem)item);
+ break;
+ }
+
+ if (step == null) {
+ step = super.createSurveyStep(context, item, isSubtaskStep);
+ }
+
+ // Maintain a list that can then be used to create different tasks from this stepList
+ // Only add root steps, aka if they are not already in a subtask step
+ if (step != null && !isSubtaskStep) {
+ stepList.add(step);
+ }
+
+ return step;
+ }
+
+ /**
+ * Creates consent review steps, which can be a total of name, birthdate step,
+ * SignatureStep, but always a consent doc review step
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item ConsentReviewSurveyItem used to create steps
+ * @return ConsentReviewSubstepListStep used for consent review
+ */
+ public ConsentReviewSubstepListStep createConsentReviewSteps(Context context, ConsentReviewSurveyItem item) {
+ List stepList = new ArrayList<>();
+
+ if (item.items == null) {
+ // Default to name and birthdate
+ item.items = new ArrayList<>();
+ item.items.add(ProfileInfoOption.NAME.getIdentifier());
+ item.items.add(ProfileInfoOption.BIRTHDATE.getIdentifier());
+ }
+
+ // This is a deprecated way of supplying requiresName and requiresBirthdate
+ // But we are going to support it for now, but use "name", and "birthdate" moving forward
+ if (consentDocument.getDocumentProperties() != null) {
+ // Check if birthdate and name are required, and remove them if they are not
+ if (!consentDocument.getDocumentProperties().requiresName() && item.items != null) {
+ item.items.remove(ProfileInfoOption.NAME.getIdentifier());
+ }
+ if (!consentDocument.getDocumentProperties().requiresBirthdate()) {
+ item.items.remove(ProfileInfoOption.BIRTHDATE.getIdentifier());
+ }
+ }
+
+ // Only create the profile step if there is at least one consent required item
+ if (item.items != null && !item.items.isEmpty()) {
+ String oldIdentifier = new String(item.identifier);
+ item.identifier = CONSENT_REVIEW_PROFILE_IDENTIFIER;
+ // This will create a profile step with name, and birthday, or w/e is in JSON
+ ProfileStep profileStep = super.createProfileStep(context, item);
+ profileStep.setOptional(false);
+ stepList.add(profileStep);
+ item.identifier = oldIdentifier;
+ }
+
+ if (consentDocument.getDocumentProperties() != null &&
+ consentDocument.getDocumentProperties().requiresSignature())
+ {
+ // Add Consent Signature
+ stepList.add(createConsentSignatureStep(context, item));
+ }
+
+ // Find the html consent review doc, since it can come from a deprecated or modern place
+ String htmlConsentDoc = consentDocument.getHtmlReviewContent(); // deprecated way
+ if (htmlConsentDoc == null) { // more modern way
+ for (ConsentSection section : consentDocument.getSections()) {
+ if (section.getType() == ConsentSection.Type.OnlyInDocument) {
+ htmlConsentDoc = section.getHtmlContent();
+ }
+ }
+ }
+ // Add Consent document review step here
+ ConsentDocumentStep docReviewStep = new ConsentDocumentStep(item.identifier);
+ docReviewStep.setConsentHTML(htmlConsentDoc);
+ stepList.add(docReviewStep);
+
+ return new ConsentReviewSubstepListStep(item.identifier, stepList);
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item ConsentSharingOptionsSurveyItem that may have Sharing option choices
+ * @return ConsentSharingStep for designating how to share the user's data
+ */
+ public ConsentSharingStep createConsentSharingStep(Context context, ConsentSharingOptionsSurveyItem item) {
+ AnswerFormat format = null;
+ if (item.items != null && !item.items.isEmpty()) {
+ Choice[] choices = item.items.toArray(new Choice[item.items.size()]);
+ format = new ChoiceAnswerFormat(AnswerFormat.ChoiceAnswerStyle.SingleChoice, choices);
+ } else if (item.investigatorLongDescription != null) {
+ String shareWidely = context.getString(R.string.rsb_consent_share_widely, item.investigatorLongDescription);
+ Choice shareWidelyChoice = new Choice<>(shareWidely, true);
+ String shareRestricted = context.getString(R.string.rsb_consent_share_only, item.investigatorLongDescription);
+ Choice shareRestrictedChoice = new Choice<>(shareRestricted, false);
+ Choice[] choices = new Choice[] { shareWidelyChoice, shareRestrictedChoice };
+ format = new ChoiceAnswerFormat(AnswerFormat.ChoiceAnswerStyle.SingleChoice, choices);
+ }
+ ConsentSharingStep step;
+ if (format == null) {
+ step = new ConsentSharingStep(CONSENT_SHARING_IDENTIFIER);
+ } else {
+ step = new ConsentSharingStep(CONSENT_SHARING_IDENTIFIER, item.title, format);
+ }
+
+ if (item.title == null) {
+ step.setTitle(context.getString(R.string.rsb_consent_share_title));
+ }
+
+ if (item.text != null) {
+ step.setText(item.text);
+ } else if (item.investigatorLongDescription != null) {
+ if (item.learnMoreHTMLContentURL != null) {
+ step.setText(context.getString(R.string.rsb_consent_share_description,
+ item.investigatorLongDescription,
+ item.learnMoreHTMLContentURL));
+ }
+ }
+
+ return step;
+ }
+
+ /**
+ * @param item the survey item to be converted into a step
+ * @param sections used to create the ConsentVisualSteps
+ * @return Ordered list of ConsentVisualSteps
+ */
+ public SubtaskStep createConsentVisualSteps(SurveyItem item, List sections) {
+ List stepList = new ArrayList<>();
+ int customIdx = 1;
+ int sectionIdx = 0;
+ for (ConsentSection section : sections) {
+ // OnlyInDocument is used to create the ConsentDocumentStep later on
+ if (section.getType() != ConsentSection.Type.OnlyInDocument) {
+ ConsentVisualStep step;
+ if (section.getType() == ConsentSection.Type.Custom) {
+ step = createVisualStep(section.getTypeIdentifier() + customIdx,
+ sectionIdx, sections.size());
+ customIdx++;
+ } else {
+ step = createVisualStep(section.getTypeIdentifier()
+ , sectionIdx, sections.size());
+ }
+ step.setSection(section);
+ stepList.add(step);
+ sectionIdx++;
+ }
+ }
+ return new SubtaskStep(item.getTypeIdentifier(), stepList);
+ }
+
+ /**
+ * This can be overridden by sub-classes to easily provide a custom ConsentVisualStep
+ * @param identifier the step identifier
+ * @param sectionIndex the index of this particular section within the sectionCount
+ * @param sectionCount the total number of consent visual sections
+ * @return a ConsentVisualStep to be included in the visual consent SubtaskStep
+ */
+ protected ConsentVisualStep createVisualStep(String identifier, int sectionIndex, int sectionCount) {
+ return new ConsentVisualStep(identifier, sectionIndex, sectionCount);
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item used to create signature step
+ * @return ConsentSignatureStep
+ */
+ public ConsentSignatureStep createConsentSignatureStep(Context context, ConsentReviewSurveyItem item) {
+ ConsentSignatureStep step = new ConsentSignatureStep(CONSENT_SIGNATURE_IDENTIFIER, item.title, item.text);
+ if (item.title == null) {
+ step.setTitle(context.getString(R.string.rsb_consent_signature_title));
+ }
+ if (item.text == null) {
+ step.setTitle(context.getString(R.string.rsb_consent_signature_instruction));
+ }
+ return step;
+ }
+
+ /**
+ * @param item to make the reconsent step
+ * @return an instruction step that is shown with reconsent
+ */
+ public ReConsentInstructionStep createReConsentInstructionStep(InstructionSurveyItem item) {
+ ReConsentInstructionStep step = new ReConsentInstructionStep(item.identifier, item.title, item.text);
+ fillInstructionStep(step, item);
+ return step;
+ }
+
+ /**
+ * After all survey items have been processed into steps, use this method to get all
+ * ConsentVisualSteps
+ * @return list of all the ConsentVisualStep
+ */
+ public List visualConsentSteps() {
+ List steps = new ArrayList<>();
+ for (Step step : getSteps()) {
+ if (step instanceof ConsentVisualStep) {
+ steps.add(step);
+ }
+ }
+ return steps;
+ }
+
+ public SubtaskStep reconsentStep() {
+ // Strip out the registration steps, and only leave the consent steps
+ List steps = new ArrayList<>();
+ for (Step step : getSteps()) {
+ if (!isRegistrationStep(step)) {
+ steps.add(step);
+ }
+ }
+ return new NavigationSubtaskStep(OnboardingSection.CONSENT_IDENTIFIER, steps);
+ }
+
+ boolean isRegistrationStep(Step step) {
+ // TODO: add ExternalIdStep here when it is created
+ return step instanceof RegistrationStep; // || step instanceof ExternalIdStep;
+ }
+
+ /**
+ * @return subtask step with only the steps required for consent or reconsent on login
+ */
+ public Step loginConsentStep() {
+ // Strip out the registration steps, and only leave the consent steps
+ List steps = new ArrayList<>();
+ for (Step step : getSteps()) {
+ if (!isRegistrationStep(step)) {
+ steps.add(step);
+ }
+ }
+ // Consent subtask step will be skipped if user has already consented
+ return new ConsentSubtaskStep(OnboardingSection.CONSENT_IDENTIFIER, steps);
+ }
+
+ /**
+ * Return subtask step with only the steps required for initial registration
+ * @return the consent step that is applicable during registration
+ */
+ public Step registrationConsentStep() {
+ // If this is a step that conforms to the custom step protocol and the custom step type is
+ // a reconsent subtype, then this is not to be included in the registration steps
+ // Strip out the registration steps, and only leave the consent steps
+ List steps = new ArrayList<>();
+ for (Step step : getSteps()) {
+ if (!(step instanceof ReConsentInstructionStep)) {
+ steps.add(step);
+ }
+ }
+ return new NavigationSubtaskStep(OnboardingSection.CONSENT_IDENTIFIER, steps);
+ }
+
+ public void setConsentDocument(ConsentDocument consentDocument) {
+ this.consentDocument = consentDocument;
+ }
+
+ private List getSteps() {
+ return stepList;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/survey/factory/SurveyFactory.java b/backbone/src/main/java/org/researchstack/backbone/model/survey/factory/SurveyFactory.java
new file mode 100644
index 000000000..600e87a0a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/survey/factory/SurveyFactory.java
@@ -0,0 +1,1064 @@
+package org.researchstack.backbone.model.survey.factory;
+
+import android.content.Context;
+import android.os.Build;
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+import androidx.annotation.VisibleForTesting;
+import androidx.core.hardware.fingerprint.FingerprintManagerCompat;
+import android.text.InputType;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.answerformat.AnswerFormat;
+import org.researchstack.backbone.answerformat.BooleanAnswerFormat;
+import org.researchstack.backbone.answerformat.ChoiceAnswerFormat;
+import org.researchstack.backbone.answerformat.DateAnswerFormat;
+import org.researchstack.backbone.answerformat.DecimalAnswerFormat;
+import org.researchstack.backbone.answerformat.EmailAnswerFormat;
+import org.researchstack.backbone.answerformat.GenderAnswerFormat;
+import org.researchstack.backbone.answerformat.IntegerAnswerFormat;
+import org.researchstack.backbone.answerformat.PasswordAnswerFormat;
+import org.researchstack.backbone.answerformat.TextAnswerFormat;
+import org.researchstack.backbone.model.Choice;
+import org.researchstack.backbone.model.ProfileInfoOption;
+import org.researchstack.backbone.model.TaskModel;
+import org.researchstack.backbone.model.survey.ActiveStepSurveyItem;
+import org.researchstack.backbone.model.survey.BooleanQuestionSurveyItem;
+import org.researchstack.backbone.model.survey.ChoiceQuestionSurveyItem;
+import org.researchstack.backbone.model.survey.FormSurveyItem;
+import org.researchstack.backbone.model.survey.DateRangeSurveyItem;
+import org.researchstack.backbone.model.survey.FloatRangeSurveyItem;
+import org.researchstack.backbone.model.survey.IntegerRangeSurveyItem;
+import org.researchstack.backbone.model.survey.ProfileSurveyItem;
+import org.researchstack.backbone.model.survey.ScaleQuestionSurveyItem;
+import org.researchstack.backbone.model.survey.InstructionSurveyItem;
+import org.researchstack.backbone.model.survey.QuestionSurveyItem;
+import org.researchstack.backbone.model.survey.SubtaskQuestionSurveyItem;
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.model.survey.SurveyItemType;
+import org.researchstack.backbone.model.survey.TextfieldSurveyItem;
+import org.researchstack.backbone.model.survey.TimingRangeQuestionSurveyItem;
+import org.researchstack.backbone.onboarding.OnboardingSection;
+import org.researchstack.backbone.step.CompletionStep;
+import org.researchstack.backbone.step.EmailVerificationStep;
+import org.researchstack.backbone.step.EmailVerificationSubStep;
+import org.researchstack.backbone.step.FormStep;
+import org.researchstack.backbone.step.InstructionStep;
+import org.researchstack.backbone.step.LoginStep;
+import org.researchstack.backbone.step.NavigationFormStep;
+import org.researchstack.backbone.step.PasscodeStep;
+import org.researchstack.backbone.step.PermissionsStep;
+import org.researchstack.backbone.step.ProfileStep;
+import org.researchstack.backbone.step.QuestionStep;
+import org.researchstack.backbone.step.RegistrationStep;
+import org.researchstack.backbone.step.ShareTheAppStep;
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.step.SubtaskStep;
+import org.researchstack.backbone.step.NavigationExpectedAnswerQuestionStep;
+import org.researchstack.backbone.step.NavigationSubtaskStep;
+import org.researchstack.backbone.step.active.ActiveStep;
+import org.researchstack.backbone.task.SmartSurveyTask;
+import org.researchstack.backbone.ui.ActiveTaskActivity;
+import org.researchstack.backbone.ui.step.layout.PasscodeCreationStepLayout;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Created by TheMDP on 12/29/16.
+ *
+ * The SurveyFactory controls converting SurveyItem object to Step objects
+ * It accounts for all the variations specified in SurveyItemType when looping through each
+ * SurveyItem and storing the result in a field you can access using the getSteps method
+ *
+ * Note that SurveyItem objects should be create from JSON using the GSON library,
+ * and a SurveyItemAdapter class to do special de-serialization for the SurveyItems
+ */
+
+public class SurveyFactory {
+ /** Singleton instance. */
+ public static final SurveyFactory INSTANCE = new SurveyFactory();
+
+ // The rest of them use the toString of ProfileInfoOption
+ public static final String EMAIL_VERIFICATION_SUBSTEP_IDENTIFIER = "emailVerificationSubstep";
+ public static final String PASSWORD_CONFIRMATION_IDENTIFIER = "confirmation";
+ public static final String CONSENT_QUIZ_IDENTIFIER = "consentQuiz";
+
+ @VisibleForTesting
+ static final int EXTERNAL_ID_MAX_LENGTH = 128;
+
+ private static final List EXTERNAL_ID_LOGIN_OPTIONS;
+ static {
+ List tempList = new ArrayList<>();
+ tempList.add(ProfileInfoOption.EXTERNAL_ID);
+ EXTERNAL_ID_LOGIN_OPTIONS = Collections.unmodifiableList(tempList);
+ }
+
+ // When set, this will be used
+ private CustomStepCreator customStepCreator;
+
+ /**
+ * Can be used to make a SurveyFactor and take advantage of its SurveyItem to Step methods
+ */
+ public SurveyFactory() {
+ super();
+ // Default constructor, mainly used for subclasses
+ }
+
+ /**
+ * Create a SmartSurveyTask for the given Context and TaskModel
+ *
+ * @param context activity context
+ * @param taskModel task model to create the survey task from
+ * @return created survey task
+ */
+ @NonNull
+ public SmartSurveyTask createSmartSurveyTask(
+ @NonNull Context context, @NonNull TaskModel taskModel) {
+ return new SmartSurveyTask(context, taskModel);
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param surveyItems a list of survey items that will be transformed into Steps
+ * @return a list of steps
+ */
+ public List createSurveySteps(Context context, List surveyItems) {
+ List steps = new ArrayList<>();
+ if (surveyItems != null) {
+ for (SurveyItem item : surveyItems) {
+ Step step = createSurveyStep(context, item, false);
+ if (step != null) {
+ steps.add(step);
+ }
+ }
+ }
+ return steps;
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item the survey item to act upon
+ * @param isSubtaskStep true if this is within a subtask step already, false otherwise
+ * @return a step created from the item
+ */
+ public Step createSurveyStep(Context context, SurveyItem item, boolean isSubtaskStep) {
+
+ switch (item.type) {
+ case INSTRUCTION:
+ case INSTRUCTION_COMPLETION:
+ if (!(item instanceof InstructionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, INSTRUCTION types must be InstructionSurveyItems");
+ }
+ if (item.type == SurveyItemType.INSTRUCTION_COMPLETION) {
+ return createInstructionCompletionStep((InstructionSurveyItem)item);
+ }
+ return createInstructionStep((InstructionSurveyItem)item);
+ case SUBTASK:
+ if (!(item instanceof SubtaskQuestionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, SUBTASK types must be SubtaskQuestionSurveyItem");
+ }
+ return createSubtaskStep(context, ((SubtaskQuestionSurveyItem)item));
+ case QUESTION_BOOLEAN:
+ case QUESTION_DATE:
+ case QUESTION_DATE_TIME:
+ case QUESTION_DECIMAL:
+ case QUESTION_DURATION:
+ case QUESTION_INTEGER:
+ case QUESTION_MULTIPLE_CHOICE:
+ case QUESTION_SCALE:
+ case QUESTION_SINGLE_CHOICE:
+ case QUESTION_TEXT:
+ case QUESTION_EMAIL:
+ case QUESTION_TIME:
+ case QUESTION_TIMING_RANGE:
+ if (!(item instanceof QuestionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_* types must be QuestionSurveyItem");
+ }
+ return createQuestionStep(context, (QuestionSurveyItem)item);
+ case QUESTION_FORM:
+ if (!(item instanceof FormSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_FORM types must be FormSurveyItem");
+ }
+ return createFormStep(context, (FormSurveyItem)item);
+ case ACCOUNT_REGISTRATION:
+ if (!(item instanceof ProfileSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACCOUNT_REGISTRATION types must be ProfileSurveyItem");
+ }
+ return createRegistrationStep(context, (ProfileSurveyItem)item);
+ case ACCOUNT_PROFILE:
+ if (!(item instanceof ProfileSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACCOUNT_PROFILE types must be ProfileSurveyItem");
+ }
+ return createProfileStep(context, (ProfileSurveyItem)item);
+ case ACCOUNT_LOGIN:
+ if (!(item instanceof ProfileSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACCOUNT_LOGIN types must be ProfileSurveyItem");
+ }
+ return createLoginStep(context, (ProfileSurveyItem)item, defaultLoginOptions());
+ case ACCOUNT_LOGIN_VIA_EMAIL:
+ if (!(item instanceof ProfileSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACCOUNT_LOGIN types must be ProfileSurveyItem");
+ }
+ return createLoginStep(context, (ProfileSurveyItem)item, Arrays.asList
+ (ProfileInfoOption.EMAIL));
+ case ACCOUNT_COMPLETION:
+ // TODO: finish the completion step layout, for now just use a simple instruction
+ // TODO: should show the cool check mark animation, see iOS
+ if (!(item instanceof InstructionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACCOUNT_COMPLETION types must be InstructionSurveyItem");
+ }
+ return createInstructionStep((InstructionSurveyItem)item);
+ case ACCOUNT_EMAIL_VERIFICATION:
+ if (!(item instanceof InstructionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACCOUNT_EMAIL_VERIFICATION types must be InstructionSurveyItem");
+ }
+ return createEmailVerificationStep(context, (InstructionSurveyItem)item);
+ case ACCOUNT_PERMISSIONS:
+ return createPermissionsStep(item);
+ case ACCOUNT_DATA_GROUPS:
+ return createNotImplementedStep(item);
+ case ACCOUNT_EXTERNAL_ID:
+ if (!(item instanceof ProfileSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, " +
+ "ACCOUNT_EXTERNAL_ID types must be ProfileSurveyItem");
+ }
+ return createLoginStep(context, (ProfileSurveyItem)item, EXTERNAL_ID_LOGIN_OPTIONS);
+ case PASSCODE:
+ return createPasscodeStep(context, item);
+ case SHARE_THE_APP:
+ if (!(item instanceof InstructionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, SHARE_THE_APP types must be InstructionSurveyItem");
+ }
+ return createShareTheAppStep(context, (InstructionSurveyItem)item);
+ case ACTIVE_STEP:
+ if (!(item instanceof ActiveStepSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, ACTIVE_STEP types must be ActiveStepSurveyItem");
+ }
+ return createActiveStep(context, (ActiveStepSurveyItem)item);
+ case CUSTOM:
+ // To override a custom step from survey item mapping,
+ // You need to override the CustomStepCreator
+ if (customStepCreator != null) {
+ Step step = customStepCreator.createCustomStep(context, item, isSubtaskStep, this);
+ if (step != null) {
+ return step;
+ }
+ }
+ return createCustomStep(context, item, isSubtaskStep);
+ }
+
+ return null;
+ }
+
+ /**
+ * @param item InstructionSurveyItem from JSON
+ * @return valid InstructionStep matching the InstructionSurveyItem
+ */
+ public InstructionStep createInstructionStep(InstructionSurveyItem item) {
+ InstructionStep step = new InstructionStep(item.identifier, item.title, item.text);
+ fillInstructionStep(step, item);
+ return step;
+ }
+
+ /**
+ * @param item InstructionSurveyItem from JSON
+ * @return valid CompletionStep matching the InstructionSurveyItem
+ */
+ public CompletionStep createInstructionCompletionStep(InstructionSurveyItem item) {
+ CompletionStep step = new CompletionStep(item.identifier, item.title, item.text);
+ fillInstructionStep(step, item);
+ return step;
+ }
+
+ /** helper to display to dev that this step is not implemented yet */
+ InstructionStep createNotImplementedStep(SurveyItem item) {
+ return new InstructionStep(
+ item.identifier,
+ "Not implemented",
+ "Type of step not implemented yet.");
+ }
+
+ /**
+ * Helper method for instruction steps
+ *
+ * @param step the instruction step to fill with the item's info
+ * @param item the item to fil up the step with its fields
+ */
+ public void fillInstructionStep(InstructionStep step, InstructionSurveyItem item) {
+ if (item.footnote != null) {
+ step.setFootnote(item.footnote);
+ }
+ if (item.nextIdentifier != null) {
+ step.setNextStepIdentifier(item.nextIdentifier);
+ }
+ if (item.detailText != null) {
+ step.setMoreDetailText(item.detailText);
+ }
+ if (item.image != null) {
+ step.setImage(item.image);
+ }
+ if (item.iconImage != null) {
+ step.setIconImage(item.iconImage);
+ }
+ if (item.scaleType != null) {
+ step.scaleType = item.scaleType;
+ }
+ if (item.isImageAnimated) {
+ step.setIsImageAnimated(true);
+ }
+ if (item.animationRepeatDuration > 0) {
+ step.setAnimationRepeatDuration(item.animationRepeatDuration);
+ }
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item SubtaskQuestionSurveyItem item from JSON that contains nested SurveyItems
+ * @return a subtask step by recursively calling createSurveyStep for inner subtask steps
+ */
+ public SubtaskStep createSubtaskStep(Context context, SubtaskQuestionSurveyItem item) {
+ if (item.items == null || item.items.isEmpty()) {
+ throw new IllegalStateException("subtasks must have step items to proceed");
+ }
+
+ List substeps = new ArrayList<>();
+ for (SurveyItem subItem : item.items) {
+ Step step = createSurveyStep(context, subItem, true);
+ substeps.add(step);
+ }
+
+ SubtaskStep step;
+ if (item.usesNavigation()) {
+ NavigationSubtaskStep navStep = new NavigationSubtaskStep(item.identifier, substeps);
+ transferNavigationRules(item, navStep);
+ step = navStep;
+ } else {
+ step = new SubtaskStep(item.identifier, substeps);
+ }
+
+ return step;
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item SubtaskQuestionSurveyItem item from JSON that contains nested SurveyItems
+ * @return a subtask step by recursively calling createSurveyStep for inner subtask steps
+ */
+ public FormStep createFormStep(Context context, FormSurveyItem item) {
+ if (item.items == null || item.items.isEmpty()) {
+ throw new IllegalStateException("compound surveys must have step items to proceed");
+ }
+ List questionSteps = formStepCreateQuestionSteps(context, item);
+ NavigationFormStep step = new NavigationFormStep(item.identifier, item.title, item.text, questionSteps);
+ fillNavigationFormStep(step, item);
+ return step;
+ }
+
+ /**
+ * Helper method to re-use the logic of creating question steps for a form step
+ */
+ protected List formStepCreateQuestionSteps(Context context, FormSurveyItem item) {
+ List questionSteps = new ArrayList<>();
+ for (SurveyItem subItem : item.items) {
+ if (subItem instanceof QuestionSurveyItem) {
+ QuestionStep step = createQuestionStep(context, (QuestionSurveyItem)subItem);
+ questionSteps.add(step);
+ }
+ }
+ return questionSteps;
+ }
+
+ /**
+ * Helper method to fill a navigation form step, but leave the base class out of it
+ */
+ protected void fillNavigationFormStep(NavigationFormStep step, FormSurveyItem item) {
+ fillFormStep(step, item);
+ transferNavigationRules(item, step);
+ if (item.expectedAnswer != null) {
+ step.setExpectedAnswer(item.expectedAnswer);
+ }
+ }
+
+ /**
+ * Helper method to fill a form step, but leave the base class out of it
+ */
+ protected void fillFormStep(FormStep step, FormSurveyItem item) {
+ fillQuestionStep(item, step);
+ if (item.skipTitle != null) {
+ step.setSkipTitle(item.skipTitle);
+ // we can assume that if we set the skip title, we want to show the skip button
+ step.setOptional(true);
+ }
+ if (item.autoFocusFirstEditText != null) {
+ step.setAutoFocusFirstEditText(item.autoFocusFirstEditText);
+ }
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item QuestionSurveyItem from JSON
+ * @return QuestionStep converted from the item
+ */
+ public QuestionStep createQuestionStep(Context context, QuestionSurveyItem item) {
+ AnswerFormat format = null;
+ switch (item.type) {
+ case QUESTION_BOOLEAN:
+ if (!(item instanceof BooleanQuestionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_BOOLEAN types must be BooleanQuestionSurveyItem");
+ }
+ BooleanAnswerFormat boolFormat = new BooleanAnswerFormat();
+ fillBooleanAnswerFormat(context, boolFormat, (BooleanQuestionSurveyItem)item);
+ format = boolFormat;
+ break;
+ case QUESTION_DATE:
+ case QUESTION_DATE_TIME:
+ if (!(item instanceof DateRangeSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_DATE types must be DateRangeSurveyItem");
+ }
+ DateRangeSurveyItem dateSurveyItem = (DateRangeSurveyItem)item;
+ DateRangeSurveyItem dateItem = (DateRangeSurveyItem)item;
+ AnswerFormat.DateAnswerStyle style = AnswerFormat.DateAnswerStyle.Date;
+ if (dateSurveyItem.type == SurveyItemType.QUESTION_DATE_TIME) {
+ style = AnswerFormat.DateAnswerStyle.DateAndTime;
+ }
+ format = new DateAnswerFormat(style, dateItem.min, dateItem.max, dateItem.defaultValue);
+ break;
+ case QUESTION_TIME:
+ format = new DateAnswerFormat(AnswerFormat.DateAnswerStyle.TimeOfDay);
+ break;
+ case QUESTION_DECIMAL:
+ if (!(item instanceof FloatRangeSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_DECIMAL types must be FloatRangeSurveyItem");
+ }
+ FloatRangeSurveyItem floatItem = (FloatRangeSurveyItem)item;
+ format = new DecimalAnswerFormat(floatItem.min, floatItem.max);
+ break;
+ case QUESTION_INTEGER:
+ if (!(item instanceof IntegerRangeSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_INTEGER types must be IntegerRangeSurveyItem");
+ }
+ IntegerAnswerFormat integerFormat = new IntegerAnswerFormat();
+ fillIntegerAnswerFormat(integerFormat, (IntegerRangeSurveyItem)item);
+ format = integerFormat;
+ break;
+ case QUESTION_DURATION:
+ // TODO: create DurationQuestionSurveyItem and also TimeIntervalAnswerFormat
+ format = new ChoiceAnswerFormat(AnswerFormat.ChoiceAnswerStyle.SingleChoice,
+ new Choice<>("TODO: Duration type not implemented", true));
+ break;
+ case QUESTION_SCALE:
+ if (!(item instanceof ScaleQuestionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_SCALE types must be ScaleQuestionSurveyItem");
+ }
+ ScaleQuestionSurveyItem scaleItem = (ScaleQuestionSurveyItem)item;
+ // TODO: create scale answer formats, see iOS versions
+ format = new ChoiceAnswerFormat(AnswerFormat.ChoiceAnswerStyle.SingleChoice,
+ new Choice<>("TODO: Scale (integer, continuous, text) survey not implemented", true));
+ break;
+ case QUESTION_MULTIPLE_CHOICE:
+ case QUESTION_SINGLE_CHOICE:
+ {
+ if (!(item instanceof ChoiceQuestionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, this type must be ChoiceQuestionSurveyItem");
+ }
+ ChoiceAnswerFormat choiceFormat = new ChoiceAnswerFormat();
+ fillChoiceAnswerFormat(choiceFormat, (ChoiceQuestionSurveyItem)item);
+ format = choiceFormat;
+ break;
+ }
+ case QUESTION_TIMING_RANGE: // Single choice question, but with "Not Sure" as an added option
+ {
+ if (!(item instanceof TimingRangeQuestionSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_TIMING_RANGE type must be TimingRangeQuestionSurveyItem");
+ }
+ if (item.items == null || item.items.isEmpty()) {
+ throw new IllegalStateException("TimingRangeQuestionSurveyItem must have Range items");
+ }
+ List> choiceList = new ArrayList<>();
+ TimingRangeQuestionSurveyItem subItem = (TimingRangeQuestionSurveyItem)item;
+ for (IntegerRangeSurveyItem integerRange : subItem.items) {
+ choiceList.add(convertToTextChoice(context, integerRange));
+ }
+ String notSure = context.getString(R.string.rsb_not_sure);
+ choiceList.add(new Choice<>(notSure, notSure));
+ Choice[] choices = choiceList.toArray(new Choice[choiceList.size()]);
+ format = new ChoiceAnswerFormat(AnswerFormat.ChoiceAnswerStyle.SingleChoice, choices);
+ break;
+ }
+ case QUESTION_TEXT: {
+ if (!(item instanceof TextfieldSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_TEXT type must be TextfieldSurveyItem");
+ }
+ TextfieldSurveyItem textfieldSurveyItem = (TextfieldSurveyItem) item;
+ TextAnswerFormat textFormat = new TextAnswerFormat();
+ fillTextAnswerFormat(textFormat, textfieldSurveyItem);
+ format = textFormat;
+ break;
+ }
+ case QUESTION_EMAIL: {
+ if (!(item instanceof TextfieldSurveyItem)) {
+ throw new IllegalStateException("Error in json parsing, QUESTION_EMAIL type must be TextfieldSurveyItem");
+ }
+ TextfieldSurveyItem textfieldSurveyItem = (TextfieldSurveyItem) item;
+ EmailAnswerFormat emailFormat = new EmailAnswerFormat();
+ fillTextAnswerFormat(emailFormat, textfieldSurveyItem);
+ format = emailFormat;
+ break;
+ }
+ default:
+ format = createCustomAnswerFormat(context, item);
+ break;
+ }
+
+ QuestionStep step = null;
+ // Attach the navigation components to the step if there are any
+ if (item.usesNavigation()) {
+ NavigationExpectedAnswerQuestionStep navStep = new NavigationExpectedAnswerQuestionStep(item.identifier, item.title, format);
+ transferNavigationRules(item, navStep);
+ step = navStep;
+ } else {
+ step = new QuestionStep(item.identifier, item.title, format);
+ }
+ fillQuestionStep(item, step);
+
+ return step;
+ }
+
+ /**
+ * This method can be overridden by a subclass to provide your own AnswerFormat that
+ * can be used to customize the QuestionBody UI
+ * @param context can be android or app
+ * @param item QuestionSurveyItem with item.getCustomTypeValue() unknown to the base SurveyFactory
+ * @return the correct AnswerFormat to this QuestionStep
+ */
+ public AnswerFormat createCustomAnswerFormat(Context context, QuestionSurveyItem item) {
+ return null; // to be implemented by subclass
+ }
+
+ protected void fillTextAnswerFormat(TextAnswerFormat format, TextfieldSurveyItem item) {
+ if (item.inputType != null) {
+ format.setInputType(item.inputType);
+ }
+ if (item.validationRegex != null) {
+ format.setValidationRegex(item.validationRegex);
+ }
+ if (item.disabled != null && item.disabled) {
+ format.setDisabled(true);
+ }
+ if (item.isMultipleLines != null && item.isMultipleLines) {
+ format.setIsMultipleLines(true);
+ }
+ if (item.maxLength != null) {
+ format.setMaximumLength(item.maxLength);
+ }
+ }
+
+ protected void fillIntegerAnswerFormat(IntegerAnswerFormat format, IntegerRangeSurveyItem item) {
+ format.setMaxValue((item.max == null) ? 0 : item.max);
+ format.setMinValue((item.min == null) ? 0 : item.min);
+ if (item.maxLength != null) {
+ format.setMaximumLength(item.maxLength);
+ }
+ }
+
+ protected void fillChoiceAnswerFormat(ChoiceAnswerFormat format, ChoiceQuestionSurveyItem item) {
+ if (item.items == null || item.items.isEmpty()) {
+ throw new IllegalStateException("ChoiceQuestionSurveyItem must have choices");
+ }
+ AnswerFormat.ChoiceAnswerStyle answerStyle = AnswerFormat.ChoiceAnswerStyle.SingleChoice;
+ if (item.type == SurveyItemType.QUESTION_MULTIPLE_CHOICE) {
+ answerStyle = AnswerFormat.ChoiceAnswerStyle.MultipleChoice;
+ }
+ format.setAnswerStyle(answerStyle);
+ Choice[] choices = item.items.toArray(new Choice[item.items.size()]);
+ format.setChoices(choices);
+ }
+
+ protected void fillBooleanAnswerFormat(Context context, BooleanAnswerFormat format, BooleanQuestionSurveyItem item) {
+ String yes = null, no = null;
+ // First try and get the true / value choices from the BooleanQuestionSurveyItem
+ // Since it may sometimes, but not always provided
+ if (item.items != null && item.items.size() == 2) {
+ for (Choice choice : item.items) {
+ if (choice.getValue()) {
+ yes = choice.getText();
+ } else {
+ no = choice.getText();
+ }
+ }
+ }
+ // If they are not provided, use the default yes / no strings for true / false
+ if (yes == null) {
+ yes = context.getString(R.string.rsb_yes);
+ }
+ if (no == null) {
+ no = context.getString(R.string.rsb_no);
+ }
+ format.setTextValues(yes, no);
+ }
+
+ protected void fillQuestionStep(QuestionSurveyItem item, QuestionStep step) {
+ step.setText(item.text);
+ step.setOptional(item.optional);
+ step.setPlaceholder(item.placeholderText);
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param profileInfoOptions type of profile item that should be included in profile form step
+ * @param addConfirmPasswordOption true if confirm password should be added with password, false otherwise
+ * @return a QuestionStep that can be used to get the correct data type for ProfileInfoOption
+ */
+ public List createQuestionSteps(
+ Context context,
+ List profileInfoOptions,
+ boolean addConfirmPasswordOption)
+ {
+ List questionSteps = new ArrayList<>();
+ for (ProfileInfoOption profileInfo : profileInfoOptions) {
+ switch (profileInfo) {
+ case EMAIL:
+ questionSteps.add(createEmailQuestionStep(context, profileInfo));
+ break;
+ case PASSWORD:
+ questionSteps.add(createPasswordQuestionStep(context, profileInfo));
+ // For password fields, we also need a "Confirm Password" field, if applicable
+ if (addConfirmPasswordOption) {
+ questionSteps.add(createConfirmPasswordQuestionStep(context));
+ }
+ break;
+ case NAME:
+ questionSteps.add(createNameQuestionStep(context, profileInfo));
+ break;
+ case BIRTHDATE:
+ questionSteps.add(createBirthdateQuestionStep(context, profileInfo));
+ break;
+ case GENDER:
+ createGenderQuestionStep(context, profileInfo);
+ break;
+ case EXTERNAL_ID:
+ questionSteps.add(createExternalIdQuestionStep(context, profileInfo));
+ break;
+ case BLOOD_TYPE: // ChoiceTextAnswerFormat, see HealthKit blood types
+ case FITZPATRICK_SKIN_TYPE: // ChoiceTextAnswerFormat
+ case WHEEL_CHAIR_USE: // boolean?
+ case HEIGHT: // ScaleAnswerFormat with units
+ case WEIGHT: // ScaleAnswerFormat with units
+ case WAKE_TIME: // DateAnswerFormat, but with Time value
+ case SLEEP_TIME: // DateAnswerFormat, but with Time value
+ // TODO: implement these when we need to
+ break;
+ }
+ }
+ return questionSteps;
+ }
+
+ /**
+ * @param context used to generate title and placeholder title for step
+ * @param profileOption used to set step identifier
+ * @return QuestionStep used for gathering user's email
+ */
+ public QuestionStep createEmailQuestionStep(Context context, ProfileInfoOption profileOption) {
+ QuestionStep emailStep = createGenericQuestionStep(context,
+ profileOption.getIdentifier(),
+ R.string.rsb_email,
+ R.string.rsb_email_placeholder,
+ new EmailAnswerFormat());
+ emailStep.setOptional(false);
+ return emailStep;
+ }
+
+ /**
+ * Create a question for External ID.
+ *
+ * @param context used to generate title and placeholder title for step
+ * @param profileOption used to set step identifier
+ * @return QuestionStep used for gathering user's external ID
+ */
+ public QuestionStep createExternalIdQuestionStep(
+ Context context, ProfileInfoOption profileOption) {
+ QuestionStep externalIdStep = createGenericQuestionStep(context,
+ profileOption.getIdentifier(),
+ R.string.rsb_external_id,
+ R.string.rsb_external_id_placeholder,
+ new TextAnswerFormat(EXTERNAL_ID_MAX_LENGTH));
+ externalIdStep.setOptional(false);
+ return externalIdStep;
+ }
+
+ /**
+ * @param context used to generate title and placeholder title for step
+ * @param profileOption used to set step identifier
+ * @return QuestionStep used for gathering user's password
+ */
+ public QuestionStep createPasswordQuestionStep(Context context, ProfileInfoOption profileOption) {
+ QuestionStep passwordStep = createGenericQuestionStep(context,
+ profileOption.getIdentifier(),
+ R.string.rsb_password,
+ R.string.rsb_password_placeholder,
+ new PasswordAnswerFormat());
+ passwordStep.setOptional(false);
+ return passwordStep;
+ }
+
+ /**
+ * @param context used to generate title and placeholder title for step
+ * @param profileOption used to set step identifier
+ * @return QuestionStep used for gathering user's password
+ */
+ public QuestionStep createNameQuestionStep(Context context, ProfileInfoOption profileOption) {
+ TextAnswerFormat format = new TextAnswerFormat();
+ format.setInputType(InputType.TYPE_TEXT_VARIATION_PERSON_NAME);
+ format.setIsMultipleLines(false);
+
+ return createGenericQuestionStep(context,
+ profileOption.getIdentifier(),
+ R.string.rsb_name,
+ R.string.rsb_name_placeholder,
+ format);
+ }
+
+ /**
+ * @param context used to generate title and placeholder title for step
+ * @return QuestionStep used for gathering user's password
+ */
+ public QuestionStep createConfirmPasswordQuestionStep(Context context) {
+ QuestionStep confirmPasswordStep = createGenericQuestionStep(context,
+ PASSWORD_CONFIRMATION_IDENTIFIER,
+ R.string.rsb_confirm_password,
+ R.string.rsb_confirm_password_placeholder,
+ new PasswordAnswerFormat());
+ confirmPasswordStep.setOptional(false);
+ return confirmPasswordStep;
+ }
+
+ /**
+ * @param context used to generate title and placeholder title for step
+ * @param profileOption that will be associated with this QuestionStep
+ * @return QuestionStep used for gathering user's password
+ */
+ public QuestionStep createBirthdateQuestionStep(Context context, ProfileInfoOption profileOption) {
+ return createGenericQuestionStep(context,
+ profileOption.getIdentifier(),
+ R.string.rsb_birthdate,
+ R.string.rsb_birthdate_placeholder,
+ new DateAnswerFormat(AnswerFormat.DateAnswerStyle.Date));
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param profileOption that will be associated with this QuestionStep
+ * @return QuestionStep used for gathering user's password
+ */
+ public QuestionStep createGenderQuestionStep(Context context, ProfileInfoOption profileOption) {
+ return createGenericQuestionStep(context,
+ profileOption.getIdentifier(),
+ R.string.rsb_gender,
+ R.string.rsb_gender_placeholder,
+ new GenderAnswerFormat(context));
+ }
+
+ /**
+ * a helper method to make a re-usable generic method for creating question steps
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param identifier the identifier for the question step
+ * @param titleRes the string resource for the title of the question step
+ * @param placeholderRes the string resource for the placeholder title for the question step
+ * @param format the answer format for the question step
+ * @return QuestionStep with title, placeholder, and format all filled in
+ */
+ public QuestionStep createGenericQuestionStep(
+ Context context,
+ String identifier,
+ @StringRes int titleRes,
+ @StringRes int placeholderRes,
+ AnswerFormat format)
+ {
+ String title = context.getString(titleRes);
+ QuestionStep step = new QuestionStep(identifier, title, format);
+ String placeholder = context.getString(placeholderRes);
+ step.setPlaceholder(placeholder);
+ step.setOptional(false);
+ return step;
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item ProfileSurveyItem from JSON
+ * @return valid RegistrationStep matching the ProfileSurveyItem
+ */
+ public RegistrationStep createRegistrationStep(Context context, ProfileSurveyItem item) {
+ List options = createProfileInfoOptions(context, item, defaultRegistrationOptions());
+ return new RegistrationStep(
+ item.identifier, item.title, item.text,
+ options, createQuestionSteps(context, options, true)); // true = create ConfirmPassword step
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item ProfileSurveyItem from JSON
+ * @return valid ProfileStep matching the ProfileSurveyItem
+ */
+ public ProfileStep createProfileStep(Context context, ProfileSurveyItem item) {
+ List options = createProfileInfoOptions(context, item, defaultProfileOptions());
+ return new ProfileStep(
+ item.identifier, item.title, item.text,
+ options, createQuestionSteps(context, options, false)); // false = dont create ConfirmPassword step
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item InstructionSurveyItem from JSON
+ * @param loginOptions A list of ProfileInfoOptions representing the fields needed for login.
+ * Can be defaultLoginOptions() for email/password, EXTERNAL_ID_LOGIN_OPTIONS
+ * @return valid EmailVerificationSubStep matching the InstructionSurveyItem
+ */
+ public LoginStep createLoginStep(
+ Context context, ProfileSurveyItem item, List loginOptions) {
+ List options = createProfileInfoOptions(context, item, loginOptions);
+ return new LoginStep(
+ item.identifier, item.title, item.text,
+ options, createQuestionSteps(context, options, false)); // false = dont create ConfirmPassword step
+ }
+
+ /** Helper for determining which profile info options to use */
+ List createProfileInfoOptions(
+ Context context,
+ ProfileSurveyItem item,
+ List defaultOptions)
+ {
+ List options = defaultOptions;
+ // Use profile info options that were provided in the JSON if available
+ if (item.items != null && !item.items.isEmpty()) {
+ options = ProfileInfoOption.toProfileInfoOptions(item.items);
+ }
+ return options;
+ }
+
+ List defaultLoginOptions() {
+ List profileInfo = new ArrayList<>();
+ profileInfo.add(ProfileInfoOption.EMAIL);
+ profileInfo.add(ProfileInfoOption.PASSWORD);
+ return profileInfo;
+ }
+
+ List defaultRegistrationOptions() {
+ List profileInfo = new ArrayList<>();
+ profileInfo.add(ProfileInfoOption.NAME);
+ profileInfo.add(ProfileInfoOption.EMAIL);
+ profileInfo.add(ProfileInfoOption.PASSWORD);
+ return profileInfo;
+ }
+
+ List defaultProfileOptions() {
+ List profileInfo = new ArrayList<>(); // blank for default profile
+ return profileInfo;
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item InstructionSurveyItem from JSON
+ * @return valid EmailVerificationStep matching the InstructionSurveyItem
+ */
+ public EmailVerificationStep createEmailVerificationStep(Context context, InstructionSurveyItem item) {
+ EmailVerificationSubStep emailSubstep = new EmailVerificationSubStep(
+ EMAIL_VERIFICATION_SUBSTEP_IDENTIFIER, item.title, item.text);
+ fillInstructionStep(emailSubstep, item);
+
+ String changeEmailTitle = context.getString(R.string.rsb_change_email_title);
+ RegistrationStep registrationStep = new RegistrationStep(
+ context, this, OnboardingSection.REGISTRATION_IDENTIFIER, changeEmailTitle, null);
+
+ EmailVerificationStep emailVerificationStep = new EmailVerificationStep(
+ item.identifier, emailSubstep, registrationStep);
+
+ return emailVerificationStep;
+ }
+
+ /**
+ * @param item SurveyItem from JSON
+ * @return valid PermissionsStep matching the SurveyItem
+ */
+ public PermissionsStep createPermissionsStep(SurveyItem item) {
+ return new PermissionsStep(item.identifier, item.title, item.text);
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item SurveyItem from JSON
+ * @return valid PasscodeStep matching the SurveyItem
+ */
+ public Step createPasscodeStep(Context context, SurveyItem item) {
+
+ PasscodeStep step = new PasscodeStep(item.identifier, item.title, item.text);
+ step.setStateOrdinal(PasscodeCreationStepLayout.State.CREATE.ordinal());
+
+ // Fingerprint API was added with api 23
+ if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ FingerprintManagerCompat fingerprintManager = FingerprintManagerCompat.from(context);
+
+ // This is by far the most secure way to store data, so if the user has it,
+ // we will make them take advantage of it; however, they can switch to passcode from the step layout
+ if (fingerprintManager.isHardwareDetected() &&
+ fingerprintManager.hasEnrolledFingerprints())
+ {
+ step.setUseFingerprint(true);
+ }
+ }
+
+ return step;
+ }
+
+ public ShareTheAppStep createShareTheAppStep(Context context, InstructionSurveyItem item) {
+ ShareTheAppStep step = new ShareTheAppStep(item.identifier, item.title, item.text);
+ fillInstructionStep(step, item);
+
+ if (item.items != null && !item.items.isEmpty()) {
+ step.setShareTypeList(ShareTheAppStep.ShareType.toShareTypeList(item.items));
+ }
+
+ if (step.getTitle() == null) {
+ step.setTitle(context.getString(R.string.rsb_share_the_app_title));
+ }
+
+ if (step.getText() == null) {
+ step.setText(context.getString(R.string.rsb_share_the_app_text));
+ }
+
+ return step;
+ }
+
+ public ActiveStep createActiveStep(Context context, ActiveStepSurveyItem item) {
+ ActiveStep step = new ActiveStep(item.identifier);
+ fillActiveStep(step, item);
+ return step;
+ }
+
+ public void fillActiveStep(ActiveStep step, ActiveStepSurveyItem item) {
+ step.setActivityClazz(ActiveTaskActivity.class);
+ if (item.title != null) {
+ step.setTitle(item.title);
+ }
+ if (item.text != null) {
+ step.setText(item.text);
+ }
+ if (item.getStepDuration() > 0) {
+ step.setStepDuration(item.getStepDuration());
+ }
+ if (item.getStepFinishedSpokenInstruction() != null) {
+ step.setFinishedSpokenInstruction(item.getStepFinishedSpokenInstruction());
+ }
+ if (item.getStepSpokenInstruction() != null) {
+ step.setSpokenInstruction(item.getStepSpokenInstruction());
+ }
+ if (item.getSpokenInstructionMap() != null) {
+ step.setSpokenInstructionMap(item.getSpokenInstructionMap());
+ }
+ if (item.getSoundRes() != null) {
+ step.setSoundRes(item.getSoundRes());
+ }
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item InstructionSurveyItem from JSON
+ * @param isSubtaskStep true if this is within a subtask step already, false otherwise
+ * @return valid CustomStep matching the InstructionSurveyItem
+ */
+ public Step createCustomStep(Context context, SurveyItem item, boolean isSubtaskStep) {
+ return new InstructionStep(item.identifier, item.title, item.text);
+ }
+
+ /*
+ * Transfers the QuestionSurveyItem nav properties over to NavigationStep
+ */
+ protected void transferNavigationRules(QuestionSurveyItem item, NavigationExpectedAnswerQuestionStep toStep) {
+ toStep.setSkipIfPassed(item.skipIfPassed);
+ toStep.setSkipToStepIdentifier(item.skipIdentifier);
+ toStep.setExpectedAnswer(item.expectedAnswer);
+ }
+
+ /*
+ * Transfers the QuestionSurveyItem nav properties over to NavigationStep
+ */
+ protected void transferNavigationRules(QuestionSurveyItem item, NavigationFormStep toStep) {
+ toStep.setSkipIfPassed(item.skipIfPassed);
+ toStep.setSkipToStepIdentifier(item.skipIdentifier);
+ }
+
+ /*
+ * Transfers the QuestionSurveyItem nav properties over to NavigationStep
+ */
+ protected void transferNavigationRules(QuestionSurveyItem item, NavigationSubtaskStep toStep) {
+ toStep.setSkipIfPassed(item.skipIfPassed);
+ toStep.setSkipToStepIdentifier(item.skipIdentifier);
+ }
+
+ public Choice convertToTextChoice(Context context, IntegerRangeSurveyItem item) {
+ String timeUnitStr;
+ switch (item.unit) {
+ case "minutes":
+ timeUnitStr = context.getString(R.string.rsb_time_minutes);
+ break;
+ case "hours":
+ timeUnitStr = context.getString(R.string.rsb_time_hours);
+ break;
+ case "days":
+ timeUnitStr = context.getString(R.string.rsb_time_days);
+ break;
+ case "weeks":
+ timeUnitStr = context.getString(R.string.rsb_time_weeks);
+ break;
+ case "months":
+ timeUnitStr = context.getString(R.string.rsb_time_months);
+ break;
+ case "years":
+ timeUnitStr = context.getString(R.string.rsb_time_years);
+ break;
+ default:
+ timeUnitStr = context.getString(R.string.rsb_time_seconds);
+ break;
+ }
+
+ // Note: in all cases, the value is returned in English so that the localized
+ // values will result in the same answer in any table. It is up to the researcher to translate.
+ if (item.max != null) {
+ // We also have a min
+ if (item.min != null) {
+ String maxStr = String.format(Locale.getDefault(), "%d %s", item.max, timeUnitStr);
+ String maxText = String.format(context.getString(R.string.rsb_range_ago), maxStr);
+
+ return new Choice<>(
+ String.format(Locale.getDefault(), "%d-%s", item.min, maxText),
+ String.format(Locale.US, "%d-%d %s ago", item.min, item.max, timeUnitStr));
+ } else { // we just have a max
+ String maSxtr = String.format(Locale.getDefault(), "%d %s", item.max, timeUnitStr);
+ String text = String.format(context.getString(R.string.rsb_less_than_ago), maSxtr);
+ return new Choice<>(text, String.format(Locale.US, "Less than %d %s ago", item.max, timeUnitStr));
+ }
+ } else { // does not have a max
+ String minStr = String.format(Locale.getDefault(), "%d %s", item.min, timeUnitStr);
+ String text = String.format(context.getString(R.string.rsb_more_than_ago), minStr);
+ return new Choice<>(text, String.format(Locale.US, "More than %d %s ago", item.min, timeUnitStr));
+ }
+ }
+
+ public void setCustomStepCreator(CustomStepCreator customStepCreator) {
+ this.customStepCreator = customStepCreator;
+ }
+
+ public CustomStepCreator getCustomStepCreator() {
+ return customStepCreator;
+ }
+
+ /**
+ * This can be used by another class to implement custom conversion from a CustomSurveyItem to a CustomStep
+ */
+ public interface CustomStepCreator {
+ Step createCustomStep(Context context, SurveyItem item, boolean isSubtaskStep, SurveyFactory factory);
+ }
+}
\ No newline at end of file
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/taskitem/ActiveTaskItem.java b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/ActiveTaskItem.java
new file mode 100644
index 000000000..25312653c
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/ActiveTaskItem.java
@@ -0,0 +1,104 @@
+package org.researchstack.backbone.model.taskitem;
+
+import com.google.gson.annotations.SerializedName;
+
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.task.factory.TaskExcludeOption;
+import org.researchstack.backbone.utils.OptionSetUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Created by TheMDP on 3/7/17.
+ *
+ * An ActiveTaskItem represents a TaskItem that will be used to build a specific ResearchStack
+ * task like the ones defined in TaskItemType. Currently, these include the Tremor Task,
+ * Tapping Task, Audio Task, and several Walking Tasks. The list can and will continue to grow.
+ */
+
+public class ActiveTaskItem extends TaskItem {
+
+ /**
+ * localizedSteps are SurveyItem steps that contain fields to transfer to their
+ * corresponding steps after the Task is built
+ */
+ @SerializedName("localizedSteps")
+ private List localizedSteps;
+
+ /**
+ * This list of String identifiers can be used to remove steps with these identifiers
+ * from the task after the Task is built
+ */
+ @SerializedName("removeSteps")
+ private List removeSteps;
+
+ /**
+ * This is an OptionSet in iOS, but in Android we must treat it as a bit-masked int
+ * See TaskExcludeOption class for bit values which are converted by OptionSetUtils class
+ */
+ @SerializedName("predefinedExclusions")
+ private int predefinedExclusions;
+
+ @SerializedName("intendedUseDescription")
+ private String intendedUseDescription;
+
+ /**
+ * The taskOptions contain and open-ended map that can be used to pass custom options from
+ * JSON to the task builder method in TaskItemFactory
+ */
+ public static final String GSON_TASK_OPTIONS_NAME = "taskOptions";
+ @SerializedName("taskOptions")
+ private Map taskOptions;
+
+ public ActiveTaskItem() {
+ super();
+ }
+
+ public Map getTaskOptions() {
+ return taskOptions;
+ }
+
+ public void setTaskOptions(Map taskOptions) {
+ this.taskOptions = taskOptions;
+ }
+
+ public List createPredefinedExclusions() {
+ if (predefinedExclusions == 0) {
+ return new ArrayList<>();
+ }
+ return OptionSetUtils.toEnumList(predefinedExclusions, TaskExcludeOption.values());
+ }
+
+ /**
+ * @param predefinedExclusions a bitmask representing a List, see OptionSetUtils
+ */
+ public void setPredefinedExclusions(int predefinedExclusions) {
+ this.predefinedExclusions = predefinedExclusions;
+ }
+
+ public List getLocalizedSteps() {
+ return localizedSteps;
+ }
+
+ public void setLocalizedSteps(List localizedSteps) {
+ this.localizedSteps = localizedSteps;
+ }
+
+ public List getRemoveSteps() {
+ return removeSteps;
+ }
+
+ public void setRemoveSteps(List removeSteps) {
+ this.removeSteps = removeSteps;
+ }
+
+ public String getIntendedUseDescription() {
+ return intendedUseDescription;
+ }
+
+ public void setIntendedUseDescription(String intendedUseDescription) {
+ this.intendedUseDescription = intendedUseDescription;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/taskitem/BaseTaskItem.java b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/BaseTaskItem.java
new file mode 100644
index 000000000..fb5770174
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/BaseTaskItem.java
@@ -0,0 +1,14 @@
+package org.researchstack.backbone.model.taskitem;
+
+/**
+ * Created by TheMDP on 3/7/17.
+ *
+ * Only needed because TaskItemAdapter needs a base concrete implementation
+ * to avoid infinite loops of de-serializing TaskItem
+ */
+
+public class BaseTaskItem extends TaskItem {
+ public BaseTaskItem() {
+ super();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItem.java b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItem.java
new file mode 100644
index 000000000..79beb9ed0
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItem.java
@@ -0,0 +1,143 @@
+package org.researchstack.backbone.model.taskitem;
+
+import com.google.gson.annotations.SerializedName;
+
+import org.researchstack.backbone.model.survey.SurveyItem;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 3/7/17.
+ *
+ * An TaskItem is the model for a JSON task that can be a number of specific
+ * tasks or even a custom one
+ */
+
+public class TaskItem {
+
+ /**
+ * If present, this will be used as the task id when the task is created
+ * Also, if taskType is missing, taskIdentifier will be used in it's place
+ */
+ static final String TASK_IDENTIFIER_GSON = "taskIdentifier";
+ @SerializedName(TASK_IDENTIFIER_GSON)
+ private String taskIdentifier;
+
+ /**
+ * The schemaIdentifier describes the output format of the TaskResult
+ * It can be skipped unless you specifically use it when accessing your db storage
+ */
+ @SerializedName("schemaIdentifier")
+ private String schemaIdentifier;
+
+ /**
+ * If taskIsOptional is true, there will be a skip task button added to the first step of the task
+ */
+ @SerializedName("optional")
+ private boolean taskIsOptional;
+
+ static final String TASK_TYPE_GSON = "taskType";
+ @SerializedName(TASK_TYPE_GSON)
+ private TaskItemType taskType;
+
+ /**
+ * insertSteps is a list of steps that will be inserted into the Task after it is built
+ */
+ @SerializedName("insertSteps")
+ private List insertSteps;
+
+ /**
+ * taskSteps are a list of SurveyItems that will be used to build the Task
+ * it is similar to insertSteps, except the whole Task will be taskSteps instead of just
+ * inserting specific steps into a pre-constructed TaskItemType
+ */
+ @SerializedName(value = "taskSteps", alternate = {"steps"})
+ private List taskSteps;
+
+
+ private String customItemTypeIdentifier;
+
+ private transient String rawJson;
+
+ public TaskItem() {
+ super();
+ }
+
+ public String getTaskIdentifier() {
+ return taskIdentifier;
+ }
+
+ public void setTaskIdentifier(String taskIdentifier) {
+ this.taskIdentifier = taskIdentifier;
+ }
+
+ public String getSchemaIdentifier() {
+ return schemaIdentifier;
+ }
+
+ public void setSchemaIdentifier(String schemaIdentifier) {
+ this.schemaIdentifier = schemaIdentifier;
+ }
+
+ public boolean isTaskIsOptional() {
+ return taskIsOptional;
+ }
+
+ public void setTaskIsOptional(boolean taskIsOptional) {
+ this.taskIsOptional = taskIsOptional;
+ }
+
+ public List getInsertSteps() {
+ return insertSteps;
+ }
+
+ public void setInsertSteps(List insertSteps) {
+ this.insertSteps = insertSteps;
+ }
+
+ public List getTaskSteps() {
+ return taskSteps;
+ }
+
+ public void setTaskSteps(List taskSteps) {
+ this.taskSteps = taskSteps;
+ }
+
+ public String getTaskTypeIdentifier() {
+ if (isCustomTask()) {
+ return customItemTypeIdentifier;
+ }
+ return taskType.getValue();
+ }
+
+ public TaskItemType getTaskType() {
+ return taskType;
+ }
+
+ public void setTaskType(TaskItemType type) {
+ this.taskType = type;
+ }
+
+ protected void setCustomTypeValue(String value) {
+ customItemTypeIdentifier = value;
+ }
+
+ public String getCustomTypeValue() {
+ return customItemTypeIdentifier;
+ }
+
+ public void setRawJson(String json) {
+ this.rawJson = json;
+ }
+
+ public String getRawJson() {
+ return rawJson;
+ }
+
+ /**
+ * @return true if the survey item type is custom, false otherwise
+ */
+ public boolean isCustomTask() {
+ return customItemTypeIdentifier != null;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItemAdapter.java b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItemAdapter.java
new file mode 100644
index 000000000..9ac292e64
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItemAdapter.java
@@ -0,0 +1,93 @@
+package org.researchstack.backbone.model.taskitem;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParseException;
+
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+import org.researchstack.backbone.model.taskitem.factory.TaskItemFactory;
+
+import java.lang.reflect.Type;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Created by TheMDP on 3/7/17.
+ *
+ * This class is the deserializer for TaskItem classes
+ * It looks at the "taskType" field, attempts to map it to this library's pre-defined types
+ * and if it does not find it, creates a custom task item
+ * the class of the custom task item can easily be controlled by overriding this
+ * adapter, and overriding the method getCustomClass
+ *
+ * To go even further and change the mapping of the custom tak item to a custom step,
+ * you should override TaskItemFactory's method public Task createCustomTask method
+ * which is the go to for converting a task item to a Task
+ */
+
+public class TaskItemAdapter implements JsonDeserializer {
+
+ @Override
+ public TaskItem deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+ JsonObject jsonObject = json.getAsJsonObject();
+
+ JsonElement typeJson = jsonObject.get(TaskItem.TASK_TYPE_GSON);
+ TaskItemType itemType = context.deserialize(typeJson, TaskItemType.class);
+
+ // This was a custom activity task item type
+ String customTypeString = null;
+ if (itemType == null) {
+ itemType = TaskItemType.CUSTOM;
+ // Some JSON can be malformed and there is no taskType, only taskIdentifier
+ // In that case let's try and still parse it by setting the task type as the task identifier
+ if (typeJson != null) {
+ customTypeString = typeJson.getAsString();
+ } else { // use "taskIdentifier" string
+ customTypeString = jsonObject.get(TaskItem.TASK_IDENTIFIER_GSON).getAsString();
+ }
+ }
+
+ TaskItem item = null;
+
+ switch (itemType) {
+ case WALKING:
+ case SHORT_WALK:
+ case VOICE:
+ case TAPPING:
+ case MOOD_SURVEY:
+ case TREMOR:
+ case MEMORY:
+ item = context.deserialize(json, ActiveTaskItem.class);
+ break;
+ case CUSTOM:
+ item = context.deserialize(json, getCustomClass(customTypeString, json));
+ item.setTaskType(itemType); // need to set CUSTOM type for surveyItem, since it is a special case
+ item.setCustomTypeValue(customTypeString);
+ break;
+ }
+
+ if (item == null) {
+ item = context.deserialize(json, BaseTaskItem.class);
+ item.setTaskType(itemType);
+ }
+
+ item.setRawJson(json.toString());
+
+ return item;
+ }
+
+ /**
+ * This can be overridden by subclasses to provide custom survey item deserialization
+ * the default deserialization is always a CustomTaskItem.class
+ * @param customType used to map to different types of survey items
+ * @param json if customType is not enough, you can use the JsonElement to determine how to parse
+ * it's contents by peeking at it's variables
+ * @return type of survey item to create from the custom class
+ */
+ public Class extends TaskItem> getCustomClass(String customType, JsonElement json) {
+ return BaseTaskItem.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItemType.java b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItemType.java
new file mode 100644
index 000000000..6c4eb9c5c
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/TaskItemType.java
@@ -0,0 +1,51 @@
+package org.researchstack.backbone.model.taskitem;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 3/7/17.
+ */
+
+public enum TaskItemType {
+
+ // Custom Task
+ CUSTOM(null),
+
+ // Active Tasks...
+ // Tapping Task
+ @SerializedName("tapping")
+ TAPPING ("tapping"),
+
+ // Memory Task
+ @SerializedName("memory")
+ MEMORY ("memory"),
+
+ // Voice Task
+ @SerializedName("voice")
+ VOICE ("voice"),
+
+ // Walking Task
+ @SerializedName("walking")
+ WALKING ("walking"),
+
+ // Short Walking Task
+ @SerializedName("shortWalk")
+ SHORT_WALK ("shortWalk"),
+
+ // Tremor Task
+ @SerializedName("tremor")
+ TREMOR ("tremor"),
+
+ // Mood Survey Task
+ @SerializedName("moodSurvey")
+ MOOD_SURVEY ("moodSurvey");
+
+ TaskItemType(String rawValue) {
+ value = rawValue;
+ }
+
+ private String value;
+ String getValue() {
+ return value;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/model/taskitem/factory/TaskItemFactory.java b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/factory/TaskItemFactory.java
new file mode 100644
index 000000000..68f5937d8
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/model/taskitem/factory/TaskItemFactory.java
@@ -0,0 +1,472 @@
+package org.researchstack.backbone.model.taskitem.factory;
+
+import android.content.Context;
+
+import com.google.gson.reflect.TypeToken;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.model.survey.ActiveStepSurveyItem;
+import org.researchstack.backbone.model.survey.InstructionSurveyItem;
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.model.taskitem.ActiveTaskItem;
+import org.researchstack.backbone.model.taskitem.TaskItem;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+import org.researchstack.backbone.step.InstructionStep;
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.step.SubtaskStep;
+import org.researchstack.backbone.step.active.ActiveStep;
+import org.researchstack.backbone.step.active.recorder.AudioRecorderSettings;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.task.OrderedTask;
+import org.researchstack.backbone.task.Task;
+import org.researchstack.backbone.task.factory.AudioTaskFactory;
+import org.researchstack.backbone.task.factory.HandTaskOptions;
+import org.researchstack.backbone.task.factory.TappingTaskFactory;
+import org.researchstack.backbone.task.factory.TremorTaskFactory;
+import org.researchstack.backbone.task.factory.WalkingTaskFactory;
+import org.researchstack.backbone.utils.LogExt;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Created by TheMDP on 3/7/17.
+ *
+ * The TaskItemFactory controls converting TaskItem objects to Task objects
+ * It accounts for all the variations specified in TaskItemType when looping through each
+ * TaskItem and storing the result in a field you can access using the getTaskList method
+ *
+ * Note that TaskItem objects should be create from JSON using the GSON library,
+ * and a TaskItemAdapter class to do special de-serialization for the TaskItems
+ */
+
+public class TaskItemFactory extends SurveyFactory {
+
+ public static final int DEFAULT_DURATION = 10; // in seconds
+ public static final int DEFAULT_WALKING_DURATION = 30; // in seconds
+ public static final int DEFAULT_WALKING_REST_DURATION = 30; // in seconds
+ public static final int DEFAULT_STEPS_PER_LEG = 100;
+
+ private static final String DURATION_KEY = "duration";
+ private static final String WALK_DURATION_KEY = "walkDuration";
+ private static final String REST_DURATION_KEY = "restDuration";
+ private static final String HAND_OPTIONS_KEY = "handOptions";
+ private static final String EXCLUDE_POSITIONS_KEY = "excludePositions";
+ private static final String SPEECH_INSTRUCTIONS_KEY = "speechInstruction";
+ private static final String SHORT_SPEECH_INSTRUCTIONS_KEY = "shortSpeechInstruction";
+ private static final String RECORDING_SETTINGS_KEY = "recordingSettings";
+ private static final String NUMBER_OF_STEPS_PER_LEG_KEY = "numberOfStepsPerLeg";
+
+ // When set, this will be used
+ private CustomTaskCreator customTaskCreator;
+
+ /*
+ * Default constructor
+ */
+ public TaskItemFactory() {
+ super();
+ }
+
+ /**
+ * @param context can be any context, activity or application, used to access "R" resources
+ * @param item TaskItem to transform into
+ * @return a Task created from the TaskItem object
+ */
+ public Task createTask(Context context, TaskItem item) {
+
+ Task task = null;
+
+ switch (item.getTaskType()) {
+ case VOICE:
+ if (!(item instanceof ActiveTaskItem)) {
+ throw new IllegalStateException("Error in json parsing, VOICE type must be ActiveTaskItem");
+ }
+ task = createVoiceTask(context, (ActiveTaskItem)item);
+ break;
+ case TAPPING:
+ if (!(item instanceof ActiveTaskItem)) {
+ throw new IllegalStateException("Error in json parsing, VOICE type must be ActiveTaskItem");
+ }
+ task = createTappingTask(context, (ActiveTaskItem)item);
+ break;
+ case TREMOR:
+ if (!(item instanceof ActiveTaskItem)) {
+ throw new IllegalStateException("Error in json parsing, TREMOR type must be ActiveTaskItem");
+ }
+ task = createTremorTask(context, (ActiveTaskItem)item);
+ break;
+ case WALKING:
+ if (!(item instanceof ActiveTaskItem)) {
+ throw new IllegalStateException("Error in json parsing, WALKING type must be ActiveTaskItem");
+ }
+ task = createWalkingTask(context, (ActiveTaskItem)item);
+ break;
+ case SHORT_WALK:
+ if (!(item instanceof ActiveTaskItem)) {
+ throw new IllegalStateException("Error in json parsing, SHORT_WALK type must be ActiveTaskItem");
+ }
+ task = createShortWalkTask(context, (ActiveTaskItem)item);
+ break;
+ case MOOD_SURVEY:
+ LogExt.e(getClass(), "Mood survey not implemented yet");
+ task = null;
+ break;
+ case MEMORY:
+ LogExt.e(getClass(), "Memory task not implemented yet");
+ task = null;
+ break;
+ case CUSTOM:
+ task = createCustomTask(context, item);
+ break;
+ }
+
+ fillTaskWithDefaultTaskItemAdditions(context, task, item);
+
+ return task;
+ }
+
+ public void fillTaskWithDefaultTaskItemAdditions(Context context, Task task, TaskItem item) {
+ // Add submit bar negative action on first step to skip task if possible
+ if (item.isTaskIsOptional()) {
+ task = addSkipActionToTask(context, task);
+ }
+
+ if (item.getInsertSteps() != null && !item.getInsertSteps().isEmpty()) {
+ if (task instanceof OrderedTask) {
+ // Insert steps are only supported for OrderedTask
+ OrderedTask orderedTask = (OrderedTask)task;
+ for (SurveyItem surveyItem : item.getInsertSteps()) {
+ // TODO: do insert steps, that can also be tasks
+ }
+ } else {
+ throw new IllegalStateException("insertSteps functionality only works with OrderedTasks");
+ }
+ }
+
+ // Special cases for active tasks that are also OrderedTasks
+ if (item instanceof ActiveTaskItem) {
+ ActiveTaskItem activeTaskItem = (ActiveTaskItem)item;
+ mapLocalizedSteps(activeTaskItem, task);
+ removeSteps(activeTaskItem, task);
+ }
+ }
+
+ protected Task addSkipActionToTask(Context context, Task task) {
+ if (!(task instanceof OrderedTask)) {
+ LogExt.e(getClass(), "Map Localized Steps only available for OrderedTasks");
+ return task;
+ }
+
+ OrderedTask orderedTask = (OrderedTask)task;
+
+ if (orderedTask.getSteps() == null || orderedTask.getSteps().isEmpty()) {
+ LogExt.e(getClass(), "Empty task will not have skip action applied");
+ return task;
+ }
+
+ Step introStep = orderedTask.getSteps().get(0);
+ if (!(introStep instanceof InstructionStep)) {
+ LogExt.e(getClass(), "Handling of an optional task is not implemented " +
+ "for tasks that do not start with InstructionStep");
+ return task;
+ }
+
+ Step lastStep = orderedTask.getSteps().get(orderedTask.getSteps().size());
+ if (orderedTask.getSteps().size() <= 1 || !(lastStep instanceof InstructionStep)) {
+ LogExt.e(getClass(), "Handling of an optional task is not implemented " +
+ "for tasks that do not end with InstructionStep");
+ return task;
+ }
+
+ InstructionStep introInstructionStep = (InstructionStep)introStep;
+
+ // Replace fields in the intro step with a direct navigation step that has a skip button
+ // to skip to the conclusion
+ String skipExplanation = context.getString(R.string.rsb_skip_activity_instruction);
+ String skipTitle = context.getString(R.string.rsb_skip_activity);
+
+ if (introInstructionStep.getMoreDetailText() == null) {
+ introInstructionStep.setMoreDetailText(skipExplanation);
+ } else {
+ introInstructionStep.setMoreDetailText(String.format("%s\n%s\n",
+ introInstructionStep.getMoreDetailText(), skipExplanation));
+ }
+
+ introInstructionStep.setSubmitBarNegativeActionSkipRule(
+ task.getIdentifier(), skipTitle, lastStep.getIdentifier());
+
+ return new NavigableOrderedTask(task.getIdentifier(), orderedTask.getSteps());
+ }
+
+ /**
+ * This method maps some fields from a SurveyItem list stored in localizedSteps to edit
+ * the corresponding Steps in the Task
+ * @param activeTaskItem An ActiveTaskItem that contains localized steps
+ * @param task the task to edit
+ */
+ protected void mapLocalizedSteps(ActiveTaskItem activeTaskItem, Task task) {
+
+ if (!(task instanceof OrderedTask)) {
+ LogExt.e(getClass(), "Map Localized Steps only available for OrderedTasks");
+ return;
+ }
+
+ OrderedTask orderedTask = (OrderedTask)task;
+
+ if (orderedTask.getSteps() == null || orderedTask.getSteps().isEmpty() ||
+ activeTaskItem.getLocalizedSteps() == null || activeTaskItem.getLocalizedSteps().isEmpty())
+ {
+ return;
+ }
+
+ // Loop through all steps in a task, and see if any of them match the localized step
+ for (Step step : orderedTask.getSteps()) {
+ SurveyItem surveyItem = findSurveyItemIdentifierMatchToStep(step, activeTaskItem.getLocalizedSteps());
+ if (surveyItem != null) {
+
+ int indexOfStep = orderedTask.getSteps().indexOf(step);
+
+ if (surveyItem.title != null) {
+ step.setTitle(surveyItem.title);
+ }
+ if (surveyItem.text != null) {
+ step.setText(surveyItem.text);
+ }
+
+ if (step instanceof InstructionStep && surveyItem instanceof InstructionSurveyItem) {
+ InstructionStep substep = (InstructionStep)step;
+ InstructionSurveyItem subsurveyItem = (InstructionSurveyItem)surveyItem;
+ if (subsurveyItem.detailText != null) {
+ substep.setMoreDetailText(subsurveyItem.detailText);
+ }
+ }
+
+ if (step instanceof ActiveStep && surveyItem instanceof ActiveStepSurveyItem) {
+ ActiveStep activeStep = (ActiveStep)step;
+ ActiveStepSurveyItem activeStepSurveyItem = (ActiveStepSurveyItem)surveyItem;
+ if (activeStepSurveyItem.getStepDuration() > 0) {
+ activeStep.setStepDuration(activeStepSurveyItem.getStepDuration());
+ }
+ if (activeStepSurveyItem.getStepSpokenInstruction() != null) {
+ activeStep.setSpokenInstruction(activeStepSurveyItem.getStepSpokenInstruction());
+ }
+ if (activeStepSurveyItem.getStepFinishedSpokenInstruction() != null) {
+ activeStep.setFinishedSpokenInstruction(activeStepSurveyItem.getStepFinishedSpokenInstruction());
+ }
+ }
+
+ // Must call this to make sure the changes stick
+ orderedTask.replaceStep(indexOfStep, step);
+ }
+ }
+ }
+
+ /**
+ * Removes the corresponding steps from the Task
+ * @param activeTaskItem An ActiveTaskItem that contains removeSteps
+ * @param task the task to edit
+ */
+ public void removeSteps(ActiveTaskItem activeTaskItem, Task task) {
+ if (!(task instanceof OrderedTask)) {
+ LogExt.e(getClass(), "Map Localized Steps only available for OrderedTasks");
+ return;
+ }
+
+ OrderedTask orderedTask = (OrderedTask)task;
+
+ if (orderedTask.getSteps() == null || orderedTask.getSteps().isEmpty() ||
+ activeTaskItem.getRemoveSteps() == null || activeTaskItem.getRemoveSteps().isEmpty())
+ {
+ return;
+ }
+
+ // Loop through all steps in a task, and see if any of them match the localized step
+ for (Step step : orderedTask.getSteps()) {
+ if (activeTaskItem.getRemoveSteps().contains(step.getIdentifier())) {
+ orderedTask.removeStep(orderedTask.getSteps().indexOf(step));
+ }
+ }
+ }
+
+ private SurveyItem findSurveyItemIdentifierMatchToStep(Step step, List surveyItemList) {
+ for (SurveyItem surveyItem : surveyItemList) {
+ if (step.getIdentifier().equals(surveyItem.getIdentifier())) {
+ return surveyItem;
+ }
+ }
+ return null;
+ }
+
+ public Task createTappingTask(Context context, ActiveTaskItem item) {
+
+ int duration = extractInt(DURATION_KEY, DEFAULT_DURATION, item.getTaskOptions());
+
+ String handOptionName = extractString(HAND_OPTIONS_KEY, HandTaskOptions.SERIALIZED_NAME_HAND_BOTH, item.getTaskOptions());
+ HandTaskOptions.Hand handOption = HandTaskOptions.toHandOption(handOptionName);
+
+ return TappingTaskFactory.twoFingerTappingIntervalTask(
+ context,
+ item.getSchemaIdentifier(),
+ item.getIntendedUseDescription(),
+ duration,
+ handOption,
+ item.createPredefinedExclusions());
+ }
+
+ public Task createVoiceTask(Context context, ActiveTaskItem item) {
+
+ String speechInstruction = extractString(SPEECH_INSTRUCTIONS_KEY, null, item.getTaskOptions());
+ String shortSpeechInstruction = extractString(SHORT_SPEECH_INSTRUCTIONS_KEY, null, item.getTaskOptions());
+ int duration = extractInt(DURATION_KEY, DEFAULT_DURATION, item.getTaskOptions());
+
+ // TODO: implement RECORDING_SETTINGS_KEY specific to Android, for now use the default
+ if (item.getTaskOptions() != null && !item.getTaskOptions().isEmpty()) {
+ // Attempt to read rest duration from JSON
+ if (item.getTaskOptions().get(RECORDING_SETTINGS_KEY) != null) {
+ // Currently, all these String constants are specifically mapped to iOS AVAudioSettings
+ // so we would need to either change the key values completely, or make a map to Android
+ LogExt.e(getClass(), "TODO: Voice task recorder settings not implemented yet on Android");
+ }
+ }
+
+ return AudioTaskFactory.audioTask(
+ context,
+ item.getSchemaIdentifier(),
+ item.getIntendedUseDescription(),
+ speechInstruction,
+ shortSpeechInstruction,
+ duration,
+ AudioRecorderSettings.defaultSettings(),
+ true,
+ item.createPredefinedExclusions());
+ }
+
+ public Task createWalkingTask(Context context, ActiveTaskItem item) {
+
+ // The walking activity is assumed to be walking back and forth rather than trying to walk down a long hallway.
+ int walkDuration = extractInt(WALK_DURATION_KEY, DEFAULT_WALKING_DURATION, item.getTaskOptions());
+ int restDuration = extractInt(REST_DURATION_KEY, DEFAULT_WALKING_REST_DURATION, item.getTaskOptions());
+
+ return WalkingTaskFactory.walkBackAndForthTask(
+ context,
+ item.getSchemaIdentifier(),
+ item.getIntendedUseDescription(),
+ walkDuration,
+ restDuration,
+ item.createPredefinedExclusions()); // TODO: may need to be the same as iOS
+ }
+
+ public Task createShortWalkTask(Context context, ActiveTaskItem item) {
+
+ // The walking activity is assumed to be walking back and forth rather than trying to walk down a long hallway.
+ int restDuration = extractInt(REST_DURATION_KEY, DEFAULT_WALKING_REST_DURATION, item.getTaskOptions());
+ int numberOfSteps = extractInt(NUMBER_OF_STEPS_PER_LEG_KEY, DEFAULT_STEPS_PER_LEG, item.getTaskOptions());
+
+ return WalkingTaskFactory.shortWalkTask(
+ context,
+ item.getSchemaIdentifier(),
+ item.getIntendedUseDescription(),
+ numberOfSteps,
+ restDuration,
+ item.createPredefinedExclusions());
+ }
+
+ public Task createTremorTask(Context context, ActiveTaskItem item) {
+ int duration = extractInt(DURATION_KEY, DEFAULT_DURATION, item.getTaskOptions());
+
+ String handOptionName = extractString(HAND_OPTIONS_KEY, HandTaskOptions.SERIALIZED_NAME_HAND_BOTH, item.getTaskOptions());
+ HandTaskOptions.Hand handOption = HandTaskOptions.toHandOption(handOptionName);
+
+ List excludeOptionList = new ArrayList<>();
+ List serializedExcludeList = extractStringList(EXCLUDE_POSITIONS_KEY, new ArrayList<>(), item.getTaskOptions());
+ for (String serialized : serializedExcludeList) {
+ excludeOptionList.add(TremorTaskFactory.toTremorExcludeOption(serialized));
+ }
+
+ return TremorTaskFactory.tremorTask(
+ context,
+ item.getSchemaIdentifier(),
+ item.getIntendedUseDescription(),
+ duration,
+ excludeOptionList,
+ handOption,
+ item.createPredefinedExclusions());
+ }
+
+ private int extractInt(String key, int defaultValue, Map options) {
+ if (options != null && !options.isEmpty()) {
+ if (options.get(key) != null &&
+ options.get(key) instanceof Number)
+ {
+ return ((Number)options.get(key)).intValue();
+ }
+ }
+ return defaultValue;
+ }
+
+ private String extractString(String key, String defaultValue, Map options) {
+ if (options != null && !options.isEmpty()) {
+ Type listType = new TypeToken
>() {}.getType();
+ // Attempt to read key from JSON
+ if (options.get(key) != null &&
+ options.get(key) instanceof String)
+ {
+ return (String)options.get(key);
+ }
+ }
+ return defaultValue;
+ }
+
+ @SuppressWarnings("unchecked") // needed for unchecked String List generic type casting
+ private List extractStringList(String key, List defaultValue, Map options) {
+ if (options != null && !options.isEmpty()) {
+ // Attempt to read key from JSON, GSON stores any Lists as a Map
+ if (options.get(key) != null &&
+ options.get(key) instanceof ArrayList)
+ {
+ ArrayList arrayList = (ArrayList) options.get(key);
+ List stringList = new ArrayList<>();
+ for (Object listItem : arrayList) {
+ if (listItem instanceof String) {
+ stringList.add((String)listItem);
+ }
+ }
+ return stringList;
+ }
+ }
+ return defaultValue;
+ }
+
+ /**
+ * Override to create your own custom Tasks
+ * @param context can be app or activity, used for resources
+ * @param item the task item to convert into a task
+ * @return a Task object made from the TaskItem
+ */
+ public Task createCustomTask(Context context, TaskItem item) {
+ List steps = super.createSurveySteps(context, item.getTaskSteps());
+
+ if (steps.size() == 1 && steps.get(0) instanceof SubtaskStep) {
+ return ((SubtaskStep)steps.get(0)).getSubtask();
+ } else {
+ return new NavigableOrderedTask(item.getSchemaIdentifier(), steps);
+ }
+ }
+
+ public CustomTaskCreator getCustomTaskCreator() {
+ return customTaskCreator;
+ }
+
+ public void setCustomTaskCreator(CustomTaskCreator customTaskCreator) {
+ this.customTaskCreator = customTaskCreator;
+ }
+
+ /**
+ * This can be used by another class to implement custom conversion from a TaskItem to a Task
+ */
+ public interface CustomTaskCreator {
+ Task createCustomTask(Context context, TaskItem item, TaskItemFactory factory);
+ }
+}
diff --git a/skin/src/main/java/org/researchstack/skin/notification/DeviceBootReceiver.java b/backbone/src/main/java/org/researchstack/backbone/notification/DeviceBootReceiver.java
similarity index 89%
rename from skin/src/main/java/org/researchstack/skin/notification/DeviceBootReceiver.java
rename to backbone/src/main/java/org/researchstack/backbone/notification/DeviceBootReceiver.java
index 8917474f7..f78d99d88 100644
--- a/skin/src/main/java/org/researchstack/skin/notification/DeviceBootReceiver.java
+++ b/backbone/src/main/java/org/researchstack/backbone/notification/DeviceBootReceiver.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin.notification;
+package org.researchstack.backbone.notification;
import android.content.BroadcastReceiver;
import android.content.Context;
diff --git a/skin/src/main/java/org/researchstack/skin/notification/NotificationConfig.java b/backbone/src/main/java/org/researchstack/backbone/notification/NotificationConfig.java
similarity index 94%
rename from skin/src/main/java/org/researchstack/skin/notification/NotificationConfig.java
rename to backbone/src/main/java/org/researchstack/backbone/notification/NotificationConfig.java
index 0190423eb..ac624aea2 100644
--- a/skin/src/main/java/org/researchstack/skin/notification/NotificationConfig.java
+++ b/backbone/src/main/java/org/researchstack/backbone/notification/NotificationConfig.java
@@ -1,9 +1,9 @@
-package org.researchstack.skin.notification;
+package org.researchstack.backbone.notification;
import android.app.Application;
import android.content.Context;
-import android.support.annotation.ColorInt;
-import android.support.annotation.DrawableRes;
+import androidx.annotation.ColorInt;
+import androidx.annotation.DrawableRes;
/**
* Configuration class for framework notifciations
diff --git a/skin/src/main/java/org/researchstack/skin/notification/SimpleNotificationConfig.java b/backbone/src/main/java/org/researchstack/backbone/notification/SimpleNotificationConfig.java
similarity index 82%
rename from skin/src/main/java/org/researchstack/skin/notification/SimpleNotificationConfig.java
rename to backbone/src/main/java/org/researchstack/backbone/notification/SimpleNotificationConfig.java
index 9886e8de2..830c59665 100644
--- a/skin/src/main/java/org/researchstack/skin/notification/SimpleNotificationConfig.java
+++ b/backbone/src/main/java/org/researchstack/backbone/notification/SimpleNotificationConfig.java
@@ -1,13 +1,13 @@
-package org.researchstack.skin.notification;
+package org.researchstack.backbone.notification;
import android.content.Context;
-import org.researchstack.skin.R;
+import org.researchstack.backbone.R;
public class SimpleNotificationConfig extends NotificationConfig {
@Override
public int getSmallIcon() {
- return R.drawable.rss_ic_notification_24dp;
+ return R.drawable.rsb_ic_notification_24dp;
}
@Override
diff --git a/skin/src/main/java/org/researchstack/skin/notification/TaskAlertReceiver.java b/backbone/src/main/java/org/researchstack/backbone/notification/TaskAlertReceiver.java
similarity index 91%
rename from skin/src/main/java/org/researchstack/skin/notification/TaskAlertReceiver.java
rename to backbone/src/main/java/org/researchstack/backbone/notification/TaskAlertReceiver.java
index 425ea40a6..1c47e6c40 100644
--- a/skin/src/main/java/org/researchstack/skin/notification/TaskAlertReceiver.java
+++ b/backbone/src/main/java/org/researchstack/backbone/notification/TaskAlertReceiver.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin.notification;
+package org.researchstack.backbone.notification;
import android.app.AlarmManager;
import android.app.PendingIntent;
@@ -12,8 +12,8 @@
import org.researchstack.backbone.utils.FormatHelper;
import org.researchstack.backbone.utils.LogExt;
import org.researchstack.backbone.utils.ObservableUtils;
-import org.researchstack.skin.UiManager;
-import org.researchstack.skin.schedule.ScheduleHelper;
+import org.researchstack.backbone.UiManager;
+import org.researchstack.backbone.schedule.ScheduleHelper;
import java.text.DateFormat;
import java.util.Date;
@@ -26,10 +26,10 @@ public class TaskAlertReceiver extends BroadcastReceiver {
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
// Intent Actions
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
- public static final String ALERT_CREATE = "org.researchstack.skin.notification.ALERT_CREATE";
- public static final String ALERT_CREATE_ALL = "org.researchstack.skin.notification.ALERT_CREATE_ALL";
- public static final String ALERT_DELETE = "org.researchstack.skin.notification.ALERT_DELETE";
- public static final String ALERT_DELETE_ALL = "org.researchstack.skin.notification.ALERT_DELETE_ALL";
+ public static final String ALERT_CREATE = "org.researchstack.backbone.notification.ALERT_CREATE";
+ public static final String ALERT_CREATE_ALL = "org.researchstack.backbone.notification.ALERT_CREATE_ALL";
+ public static final String ALERT_DELETE = "org.researchstack.backbone.notification.ALERT_DELETE";
+ public static final String ALERT_DELETE_ALL = "org.researchstack.backbone.notification.ALERT_DELETE_ALL";
//-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*-*
// Intent Keys
@@ -37,6 +37,12 @@ public class TaskAlertReceiver extends BroadcastReceiver {
public static final String KEY_NOTIFICATION = "CreateAlertReceiver.KEY_NOTIFICATION";
public static final String KEY_NOTIFICATION_ID = "CreateAlertReceiver.KEY_NOTIFICATION_ID";
+ public static Intent createCreateIntent(TaskNotification notification) {
+ Intent createTaskIntent = new Intent(TaskAlertReceiver.ALERT_CREATE);
+ createTaskIntent.putExtra(TaskAlertReceiver.KEY_NOTIFICATION, notification);
+ return createTaskIntent;
+ }
+
public static Intent createDeleteIntent(int notificationId) {
Intent deleteTaskIntent = new Intent(TaskAlertReceiver.ALERT_DELETE);
deleteTaskIntent.putExtra(TaskAlertReceiver.KEY_NOTIFICATION_ID, notificationId);
diff --git a/skin/src/main/java/org/researchstack/skin/notification/TaskNotificationReceiver.java b/backbone/src/main/java/org/researchstack/backbone/notification/TaskNotificationReceiver.java
similarity index 93%
rename from skin/src/main/java/org/researchstack/skin/notification/TaskNotificationReceiver.java
rename to backbone/src/main/java/org/researchstack/backbone/notification/TaskNotificationReceiver.java
index 6ae54463c..eefa9cefe 100644
--- a/skin/src/main/java/org/researchstack/skin/notification/TaskNotificationReceiver.java
+++ b/backbone/src/main/java/org/researchstack/backbone/notification/TaskNotificationReceiver.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin.notification;
+package org.researchstack.backbone.notification;
import android.app.Notification;
import android.app.NotificationManager;
@@ -6,10 +6,10 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
-import android.support.v7.app.NotificationCompat;
+import androidx.core.app.NotificationCompat;
import android.util.Log;
-import org.researchstack.skin.ui.MainActivity;
+import org.researchstack.backbone.ui.MainActivity;
public class TaskNotificationReceiver extends BroadcastReceiver {
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/ConsentOnboardingSection.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/ConsentOnboardingSection.java
new file mode 100644
index 000000000..d9f9b8be2
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/ConsentOnboardingSection.java
@@ -0,0 +1,30 @@
+package org.researchstack.backbone.onboarding;
+
+import android.content.Context;
+
+import org.researchstack.backbone.model.ConsentDocument;
+import org.researchstack.backbone.model.survey.factory.ConsentDocumentFactory;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+
+/**
+ * Created by TheMDP on 1/5/17.
+ */
+
+public class ConsentOnboardingSection extends OnboardingSection {
+
+ // Serialization must be done manually in OnboardingSectionAdapter
+ protected ConsentDocument consentDocument;
+
+ @Override
+ public SurveyFactory getDefaultOnboardingSurveyFactory(
+ Context context,
+ SurveyFactory.CustomStepCreator customStepCreator)
+ {
+ if (surveyFactory != null) {
+ return surveyFactory;
+ }
+
+ surveyFactory = new ConsentDocumentFactory(consentDocument, customStepCreator);
+ return surveyFactory;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/CustomOnboardingSection.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/CustomOnboardingSection.java
new file mode 100644
index 000000000..b5b851517
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/CustomOnboardingSection.java
@@ -0,0 +1,26 @@
+package org.researchstack.backbone.onboarding;
+
+/**
+ * Created by TheMDP on 1/12/17.
+ */
+
+public class CustomOnboardingSection extends OnboardingSection {
+
+ CustomOnboardingSection(String customOnboardingType) {
+ this.customOnboardingType = customOnboardingType;
+ }
+
+ @Override
+ public String getOnboardingSectionIdentifier() {
+ if (onboardingType == OnboardingSectionType.CUSTOM) {
+ return customOnboardingType;
+ }
+ return super.getOnboardingSectionIdentifier();
+ }
+
+ /**
+ * Since Enums cannot have multiple instances of of an Enum with different member variables
+ * We must store the custom identifier elsewhere
+ */
+ transient String customOnboardingType;
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingManager.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingManager.java
new file mode 100644
index 000000000..bdb97fedb
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingManager.java
@@ -0,0 +1,372 @@
+package org.researchstack.backbone.onboarding;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.annotations.SerializedName;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.ResourcePathManager;
+import org.researchstack.backbone.StorageAccess;
+import org.researchstack.backbone.model.ConsentSection;
+import org.researchstack.backbone.model.ConsentSectionAdapter;
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.model.survey.SurveyItemAdapter;
+import org.researchstack.backbone.model.survey.factory.ConsentDocumentFactory;
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.DataProvider;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+import org.researchstack.backbone.ResourceManager;
+import org.researchstack.backbone.ui.OnboardingTaskActivity;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 12/22/16.
+ *
+ * OnboardingManager is a more sophisticated version of TaskProvider
+ * It deserializes JSON from the onboarding JSON file, and converts it ultimately into Steps
+ * Most interactions with the OnboardingManager come from launchOnboarding
+ * which can launch onboarding of a certain type, and will provide Steps in the correct
+ * order in which the user will go through without much work from the app developer
+ */
+
+public class OnboardingManager implements SurveyFactory.CustomStepCreator {
+
+ static final String LOG_TAG = OnboardingManager.class.getCanonicalName();
+
+ /**
+ * Class used for easy deserialization of sections list
+ */
+ class SectionsGsonHolder {
+ @SerializedName("sections")
+ List sections;
+ }
+ private SectionsGsonHolder mSectionsGsonHolder;
+
+ /*
+ * Always initalize using this class
+ * @param Context used in reference to the ResourceManager to load JSON resources to construct
+ * the onboarding manager
+ * @return OnboardingManager set up using ResourceManager, so make sure it is initialized
+ */
+ public OnboardingManager(Context context) {
+ ResourcePathManager.Resource onboarding = ResourceManager.getInstance().getOnboardingManager();
+ String onboardingJson = ResourceManager.getResourceAsString(context,
+ ResourceManager.getInstance().generatePath(onboarding.getType(), onboarding.getName()));
+
+ Gson gson = buildGson(context); // Do not store this gson as a member variable, it has a link to Context
+ mSectionsGsonHolder = gson.fromJson(onboardingJson, SectionsGsonHolder.class);
+ Collections.sort(mSectionsGsonHolder.sections, getSectionComparator());
+ }
+
+ public List getSections() {
+ return mSectionsGsonHolder.sections;
+ }
+
+ /**
+ * Override to register custom SurveyItemAdapters,
+ * but make sure that the adapter extends from SurveyItemAdapter, and only overrides
+ * the method getCustomClass()
+ * @param builder the gson build to add the survey item adapter to
+ */
+ public void registerSurveyItemAdapter(GsonBuilder builder) {
+ builder.registerTypeAdapter(SurveyItem.class, new SurveyItemAdapter());
+ }
+
+ /**
+ * Override to register custom ConsentSectionAdapters,
+ * but make sure that the adapter extends from ConsentSectionAdapter
+ * This can allow you to provide a custom ConsentDocumentFactory
+ * @param builder the gson builder to add to
+ * @param provider that is needed to create a ConsentSectionAdapter
+ */
+ public void registerOnboardingSectionAdapter(GsonBuilder builder, AdapterContextProvider provider) {
+ builder.registerTypeAdapter(OnboardingSection.class, new OnboardingSectionAdapter(provider));
+ }
+
+ /**
+ * @param context should be activity context, just in case this is stored, but
+ * the results of this method should not be since it will store context
+ * @return a Gson to be used by the OnboardingManager
+ */
+ private Gson buildGson(final Context context) {
+ // We give access to context through this interface so that once this method
+ // is over, we won't hold a reference to context
+ final AdapterContextProvider provider = () -> context;
+
+ GsonBuilder onboardingGson = new GsonBuilder();
+ registerSurveyItemAdapter(onboardingGson);
+ onboardingGson.registerTypeAdapter(ConsentSection.class, new ConsentSectionAdapter(provider));
+ registerOnboardingSectionAdapter(onboardingGson, provider);
+ return onboardingGson.create();
+ }
+
+ // Override this to control OnboardingSection sort order
+ public Comparator getSectionComparator() {
+ return new SectionComparator();
+ }
+
+ /**
+ * When initializing an onboarding manager with a json file,
+ * the sections list will be sorted according to this class. By default,
+ * this sort function will ensure that the sections conforming to `OnboardingSectionType` field
+ * are ordered as needed to ensure the proper sequence of login, consent and registration according
+ * to their ordinal position. All custom sections are left in the order they were included in the
+ * original List.
+ **/
+ static class SectionComparator implements Comparator {
+ @Override
+ public int compare(OnboardingSection lhs, OnboardingSection rhs) {
+ if (lhs == null && rhs == null) {
+ return 0;
+ } else if (lhs == null) {
+ return 1;
+ } else if (rhs == null) {
+ return -1;
+ }
+
+ OnboardingSectionType lhsType = lhs.getOnboardingSectionType();
+ OnboardingSectionType rhsType = rhs.getOnboardingSectionType();
+
+ // If there is a CUSTOM type, just return same, or 0, so it does not shift positions
+ if (lhsType == OnboardingSectionType.CUSTOM || rhsType == OnboardingSectionType.CUSTOM) {
+ return 0;
+ }
+
+ // Passcode is a special case right now, since
+
+ Integer lhsOrdinal = lhsType.ordinal();
+ Integer rhsOrdinal = rhsType.ordinal();
+ return lhsOrdinal.compareTo(rhsOrdinal);
+ }
+ }
+
+ /**
+ * This is the method that developers should be calling to start off any onboarding process
+ * @param taskType the type of onboarding process that should be kicked off
+ * currently, it is either login, registration, or reconsent
+ * @param context used to transition app to the new onboarding activity
+ */
+ public void launchOnboarding(OnboardingTaskType taskType, Context context) {
+ if (getSections() == null) {
+ Log.e(LOG_TAG, "Improper Onboarding json file, sections is null");
+ return;
+ }
+
+ if (getSections().isEmpty()) {
+ Log.e(LOG_TAG, "Improper Onboarding json file, sections are empty");
+ return;
+ }
+
+ List steps = new ArrayList<>();
+ for (OnboardingSection section : getSections()) {
+ List subSteps = steps(context, section, taskType);
+ if (subSteps != null) {
+ setStepTitles(section, subSteps);
+ steps.addAll(subSteps);
+ }
+ }
+
+ String identifier = taskType.toString();
+
+ NavigableOrderedTask task = createOnboardingTask(identifier, steps);
+ Intent onboardingTaskIntent = createOnboardingTaskActivityIntent(context, task);
+ context.startActivity(onboardingTaskIntent);
+ }
+
+ /**
+ * Can be overridden by a sub-class to inject their own Task
+ *
+ * @param identifier Task Identifier
+ * @param stepList to make NavigableOrderedTask
+ * @return NavigableOrderedTask with step list
+ */
+ public OnboardingManagerTask createOnboardingTask(String identifier, List stepList) {
+ return new OnboardingManagerTask(identifier, stepList);
+ }
+
+ /**
+ * Intent must contain class of type OnboardingTaskActivity, otherwise it may not operate correctly
+ *
+ * @param context used to launch activity
+ * @param task to be sent to OnboardingTaskActivity or sub-class Activity
+ *
+ * @return an intent holding a reference to OnboardingTaskActivity or a sub-class of it
+ */
+ public Intent createOnboardingTaskActivityIntent(Context context, NavigableOrderedTask task) {
+ return OnboardingTaskActivity.newIntent(context, task);
+ }
+
+ /**
+ * Get the steps that should be included for a given `SBAOnboardingSection` and `SBAOnboardingTaskType`.
+ * By default, this will return the steps created using the default onboarding survey factory for that section
+ * or nil if the steps for that section should not be included for the given task.
+ * @param context used to determine if user is signed, registered, or consented
+ * @param section get the steps for this section
+ * @param taskType the type of task to control section steps
+ * @return step list for this onboarding section and the task type
+ */
+ public List steps(final Context context, OnboardingSection section, OnboardingTaskType taskType) {
+
+ // Check to see that the steps for this section should be included
+ if (!shouldInclude(context, section, taskType)) {
+ Log.d(LOG_TAG, "No sections for the task type " + taskType.ordinal());
+ return null;
+ }
+
+ // Get the default factory
+ SurveyFactory factory = section.getDefaultOnboardingSurveyFactory(context, this);
+ List stepList = factory.createSurveySteps(context, section.surveyItems);
+
+ // For consent, need to filter out steps that should not be included and group the steps into a substep.
+ // This is to facilitate skipping re-consent for a user who is logging in where it is unknown whether
+ // or not the user needs to re-consent. Returned this way because the steps in a subclass of ORKOrderedTask
+ // are immutable but can be skipped using navigation rules.
+ if (factory instanceof ConsentDocumentFactory) {
+ ConsentDocumentFactory consentFactory = (ConsentDocumentFactory)factory;
+ switch (taskType) {
+ case REGISTRATION:
+ return Collections.singletonList(consentFactory.registrationConsentStep());
+ case LOGIN:
+ return Collections.singletonList(consentFactory.loginConsentStep());
+ default: // RE_CONSENT
+ return Collections.singletonList(consentFactory.reconsentStep());
+ }
+ }
+
+ // For all other cases, return the steps.
+ return stepList;
+ }
+
+ /**
+ Define the rules for including a given section in a given task type.
+ @return `true` if the `SBAOnboardingSection` should be included for this `SBAOnboardingTaskType`
+ */
+ protected boolean shouldInclude(Context context, OnboardingSection section, OnboardingTaskType taskType) {
+ switch (section.getOnboardingSectionType()) {
+ case LOGIN:
+ return taskType == OnboardingTaskType.LOGIN;
+ case CONSENT:
+ // All types *except* email verification include consent
+ return (taskType != OnboardingTaskType.REGISTRATION) ||
+ !isRegistered(context);
+ case ELIGIBILITY:
+ case REGISTRATION:
+ // Intro, eligibility and registration are only included in registration
+ return (taskType == OnboardingTaskType.REGISTRATION) &&
+ !isRegistered(context);
+ case PASSCODE:
+ // Passcode is included if it has not already been set
+ return !hasPasscode(context);
+ case EMAIL_VERIFICATION:
+ // Only registration where the login has not been verified includes verification
+ return (taskType == OnboardingTaskType.REGISTRATION) &&
+ !isLoginVerified(context);
+ case PROFILE:
+ return (taskType == OnboardingTaskType.REGISTRATION);
+ case PERMISSIONS:
+ case COMPLETION:
+ // Permissions and completion are included for login and registration
+ return taskType == OnboardingTaskType.REGISTRATION ||
+ taskType == OnboardingTaskType.LOGIN;
+ case CUSTOM:
+ return shouldIncludeCustomSection(context, section.getOnboardingSectionIdentifier(), taskType);
+ }
+ return false;
+ }
+
+ /**
+ * @param context can be app or activity
+ * @param sectionIdentifier the custom section type identifier
+ * @param taskType the custom task type
+ * @return true if this section should be including for the taskType, false otherwise
+ */
+ public boolean shouldIncludeCustomSection(
+ Context context, String sectionIdentifier, OnboardingTaskType taskType) {
+ return false;
+ }
+
+ /**
+ * @param context used to access if login is verified
+ * @return true when the user has successfully signed in, false otherwise
+ * also, returns false on special case is if the user has signed up but not signed in yet
+ */
+ boolean isLoginVerified(Context context) {
+ return DataProvider.getInstance().isSignedIn(context);
+ }
+
+ /**
+ * @param context used to access if user is registered
+ * @return true when the user has successfully signed up, or signed in, false otherwise
+ */
+ boolean isRegistered(Context context) {
+ return DataProvider.getInstance().isSignedUp(context);
+ }
+
+ /**
+ * @param context used to access if user has made a pin code yet
+ * @return true if user has made a pin code yet, false otherwise
+ */
+ boolean hasPasscode(Context context) {
+ return StorageAccess.getInstance().hasPinCode(context);
+ }
+
+ @Override
+ public Step createCustomStep(Context context, SurveyItem item, boolean isSubtaskStep, SurveyFactory factory) {
+ return null;
+ }
+
+ /**
+ * @param section that these steps belong to
+ * @param stepList the list of steps created from this section and the JSON
+ */
+ public void setStepTitles(OnboardingSection section, List stepList) {
+ for (Step step : stepList) {
+ if (step.getStepTitle() == 0) {
+ switch (section.getOnboardingSectionType()) {
+ case LOGIN:
+ step.setStepTitle(R.string.rsb_login_step_title);
+ break;
+ case PASSCODE:
+ step.setStepTitle(R.string.rsb_passcode);
+ break;
+ case REGISTRATION:
+ step.setStepTitle(R.string.rsb_registration_step_title);
+ break;
+ case PERMISSIONS:
+ step.setStepTitle(R.string.rsb_permissions_step_title);
+ break;
+ case PROFILE:
+ step.setStepTitle(R.string.rsb_profile_step_title);
+ break;
+ case CONSENT:
+ step.setStepTitle(R.string.rsb_consent_step_title);
+ break;
+ case EMAIL_VERIFICATION:
+ step.setStepTitle(R.string.rsb_email_verification_step_title);
+ break;
+ case ELIGIBILITY:
+ step.setStepTitle(R.string.rsb_eligibility_step_title);
+ break;
+ case COMPLETION:
+ step.setStepTitle(R.string.rsb_completion_step_title);
+ break;
+ }
+ }
+ }
+ }
+
+ public interface AdapterContextProvider {
+ Context getContext();
+ }
+}
+
+
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingManagerTask.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingManagerTask.java
new file mode 100644
index 000000000..3625211ba
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingManagerTask.java
@@ -0,0 +1,51 @@
+package org.researchstack.backbone.onboarding;
+
+import android.content.Context;
+import androidx.annotation.StringRes;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.model.survey.factory.ConsentDocumentFactory;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/20/17.
+ */
+
+public class OnboardingManagerTask extends NavigableOrderedTask {
+
+ public OnboardingManagerTask(String identifier, List steps) {
+ super(identifier, steps);
+ }
+
+ public OnboardingManagerTask(String identifier, Step... steps) {
+ super(identifier, steps);
+ }
+
+ @Override
+ public String getTitleForStep(Context context, Step step) {
+ String title = null;
+
+ @StringRes int stepTitleRes = -1;
+
+ // All these are Subtasks, so identifier will be in the form of id.[question_id]
+ if (step.getStepTitle() != 0) {
+ stepTitleRes = step.getStepTitle();
+ } else if (step.getIdentifier().contains(SurveyFactory.CONSENT_QUIZ_IDENTIFIER)) {
+ stepTitleRes = R.string.rsb_consent_quiz_step_title;
+ } else if (step.getIdentifier().contains(ConsentDocumentFactory.CONSENT_REVIEW_IDENTIFIER)) {
+ stepTitleRes = R.string.rsb_consent_review_step_title;
+ } else if (step.getIdentifier().contains(ConsentDocumentFactory.CONSENT_SHARING_IDENTIFIER)) {
+ stepTitleRes = R.string.rsb_consent_review_step_title;
+ }
+
+ if (stepTitleRes != 0) {
+ return context.getString(stepTitleRes);
+ }
+
+ return title;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSection.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSection.java
new file mode 100644
index 000000000..443bd1831
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSection.java
@@ -0,0 +1,72 @@
+package org.researchstack.backbone.onboarding;
+
+import android.content.Context;
+
+import com.google.gson.annotations.SerializedName;
+
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 12/22/16.
+ */
+
+public class OnboardingSection {
+
+ /**
+ * These are pre-defined onboarding section identifiers
+ * that are used to create default helper enums for OnboardingSectionType
+ */
+ public static final String LOGIN_IDENTIFIER = "login";
+ public static final String ELIGIBILITY_IDENTIFIER = "eligibility";
+ public static final String CONSENT_IDENTIFIER = "consent";
+ public static final String REGISTRATION_IDENTIFIER = "registration";
+ public static final String PASSCODE_IDENTIFIER = "passcode";
+ public static final String EMAIL_VERIFICATION_IDENTIFIER = "emailVerification";
+ public static final String PERMISSIONS_IDENTIFIER = "permissions";
+ public static final String PROFILE_IDENTIFIER = "profile";
+ public static final String COMPLETION_IDENTIFIER = "completion";
+
+ public OnboardingSection() {
+ super();
+ }
+
+ public OnboardingSection(OnboardingSectionType type) {
+ super();
+ this.onboardingType = type;
+ }
+
+ static final String ONBOARDING_TYPE_GSON = "onboardingType";
+ @SerializedName(ONBOARDING_TYPE_GSON)
+ OnboardingSectionType onboardingType;
+ public OnboardingSectionType getOnboardingSectionType() {
+ return onboardingType;
+ }
+
+ public String getOnboardingSectionIdentifier() {
+ return onboardingType.getIdentifier();
+ }
+
+ static final String ONBOARDING_SURVEY_ITEMS_GSON = "steps";
+ @SerializedName(ONBOARDING_SURVEY_ITEMS_GSON)
+ public List surveyItems;
+
+ // Isnt deserialized into a field, but is used in the deserialization process
+ static final String ONBOARDING_RESOURCE_NAME_GSON = "resourceName";
+
+ protected transient SurveyFactory surveyFactory;
+ public SurveyFactory getDefaultOnboardingSurveyFactory(
+ Context context,
+ SurveyFactory.CustomStepCreator customStepCreator)
+ {
+ if (surveyFactory != null) {
+ return surveyFactory;
+ }
+
+ surveyFactory = new SurveyFactory();
+ surveyFactory.setCustomStepCreator(customStepCreator);
+ return surveyFactory;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSectionAdapter.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSectionAdapter.java
new file mode 100644
index 000000000..e27581a4b
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSectionAdapter.java
@@ -0,0 +1,100 @@
+package org.researchstack.backbone.onboarding;
+
+import com.google.gson.JsonDeserializationContext;
+import com.google.gson.JsonDeserializer;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonParseException;
+import com.google.gson.JsonParser;
+import com.google.gson.reflect.TypeToken;
+
+import org.researchstack.backbone.ResourceManager;
+import org.researchstack.backbone.ResourcePathManager;
+import org.researchstack.backbone.model.ConsentDocument;
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.model.survey.factory.ConsentDocumentFactory;
+
+import java.lang.reflect.Type;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/2/17.
+ */
+
+public class OnboardingSectionAdapter implements JsonDeserializer {
+
+ private OnboardingManager.AdapterContextProvider adapterProvider;
+
+ /**
+ * @param adapterProvider should provide Gson and Context, to avoid storing them as member variables in this class
+ */
+ public OnboardingSectionAdapter(OnboardingManager.AdapterContextProvider adapterProvider) {
+ this.adapterProvider = adapterProvider;
+ }
+
+ @Override
+ public OnboardingSection deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
+
+ JsonElement typeJson = json.getAsJsonObject().get(OnboardingSection.ONBOARDING_TYPE_GSON);
+ OnboardingSectionType type = context.deserialize(typeJson, OnboardingSectionType.class);
+
+ // setup custom type
+ if (type == null) {
+ type = OnboardingSectionType.CUSTOM;
+ }
+
+ JsonElement resourceName = json.getAsJsonObject().get(OnboardingSection.ONBOARDING_RESOURCE_NAME_GSON);
+ if (resourceName != null) {
+ // Android does not support spaces or uppercase letters for resource names
+ // So convert all of these before we request the resource name
+ String convertedResourceName = resourceName.getAsString().replace(" ", "_").toLowerCase();
+ ResourcePathManager.Resource resource = ResourceManager.getInstance().getResource(convertedResourceName);
+ if (resource == null) {
+ throw new IllegalStateException("Could not find resource with name " + convertedResourceName +
+ "to load in onboarding JSON. Make sure you add it with ResourceManager.addResource(), so this" +
+ "class knows where to load it from");
+ }
+ String resourceJson = ResourceManager.getResourceAsString(adapterProvider.getContext(),
+ ResourceManager.getInstance().generatePath(resource.getType(), resource.getName()));
+ JsonParser parser = new JsonParser();
+ json = parser.parse(resourceJson);
+ }
+
+ OnboardingSection section;
+ // Consent section also has a consent document with it, try and parse it if we have that type
+ if (type == OnboardingSectionType.CONSENT) {
+ ConsentOnboardingSection consentSection = createConsentOnboardingSection();
+ consentSection.consentDocument = context.deserialize(json, ConsentDocument.class);
+ section = consentSection;
+ } else if (type == OnboardingSectionType.CUSTOM) {
+ section = new CustomOnboardingSection(typeJson.getAsString());
+ } else { // otherwise make the base onboarding section class
+ section = createDefaultOnboardingSection();
+ }
+ section.onboardingType = type;
+
+ List surveyItems = context.deserialize(
+ json.getAsJsonObject().get(OnboardingSection.ONBOARDING_SURVEY_ITEMS_GSON),
+ new TypeToken>() {}.getType());
+ section.surveyItems = surveyItems;
+
+ return section;
+ }
+
+ /**
+ * Can be overridden by a sub-class to provide a custom OnboardingSection
+ * which can provide a custom SurveyFactory
+ * @return a OnboardingSection class
+ */
+ protected OnboardingSection createDefaultOnboardingSection() {
+ return new OnboardingSection();
+ }
+
+ /**
+ * Can be overridden by a sub-class to provide a custom ConsentOnboardingSection
+ * which can provide a custom ConsentDocumentFactory
+ * @return a ConsentOnboardingSection class
+ */
+ protected ConsentOnboardingSection createConsentOnboardingSection() {
+ return new ConsentOnboardingSection();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSectionType.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSectionType.java
new file mode 100644
index 000000000..783131c9d
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingSectionType.java
@@ -0,0 +1,48 @@
+package org.researchstack.backbone.onboarding;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 12/22/16.
+ */
+
+public enum OnboardingSectionType {
+ // TODO: move passcode back to where it needs to be, after registration
+ // TODO: for now, a passcode is required to save any data locally, so keep it first, here, until
+ // TODO: we figure out how to fix the root cause in the flow.
+ @SerializedName(OnboardingSection.PASSCODE_IDENTIFIER)
+ PASSCODE(OnboardingSection.PASSCODE_IDENTIFIER),
+
+ @SerializedName(OnboardingSection.LOGIN_IDENTIFIER)
+ LOGIN(OnboardingSection.LOGIN_IDENTIFIER),
+ @SerializedName(OnboardingSection.ELIGIBILITY_IDENTIFIER)
+ ELIGIBILITY(OnboardingSection.ELIGIBILITY_IDENTIFIER),
+ @SerializedName(OnboardingSection.CONSENT_IDENTIFIER)
+ CONSENT(OnboardingSection.CONSENT_IDENTIFIER),
+ @SerializedName(OnboardingSection.REGISTRATION_IDENTIFIER)
+ REGISTRATION(OnboardingSection.REGISTRATION_IDENTIFIER),
+ @SerializedName(OnboardingSection.EMAIL_VERIFICATION_IDENTIFIER)
+ EMAIL_VERIFICATION(OnboardingSection.EMAIL_VERIFICATION_IDENTIFIER),
+ @SerializedName(OnboardingSection.PERMISSIONS_IDENTIFIER)
+ PERMISSIONS(OnboardingSection.PERMISSIONS_IDENTIFIER),
+ @SerializedName(OnboardingSection.PROFILE_IDENTIFIER)
+ PROFILE(OnboardingSection.PROFILE_IDENTIFIER),
+ @SerializedName(OnboardingSection.COMPLETION_IDENTIFIER)
+ COMPLETION(OnboardingSection.COMPLETION_IDENTIFIER),
+ // Custom onboarding section, identifier should be set in OnboardingSection class
+ CUSTOM(null);
+
+ OnboardingSectionType(String identifier) {
+ this.identifier = identifier;
+ }
+
+ private String identifier;
+
+ /**
+ * @return identifier for OnboardingSectionStep, only to be used for comparison
+ * outside of the OnboardingManager
+ */
+ public String getIdentifier() {
+ return identifier;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingTaskType.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingTaskType.java
new file mode 100644
index 000000000..0d1e2d04c
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/OnboardingTaskType.java
@@ -0,0 +1,16 @@
+package org.researchstack.backbone.onboarding;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * Created by TheMDP on 12/22/16.
+ */
+
+public enum OnboardingTaskType {
+ @SerializedName("registration")
+ REGISTRATION,
+ @SerializedName("login")
+ LOGIN,
+ @SerializedName("reconsent")
+ RECONSENT;
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/onboarding/ReConsentInstructionStep.java b/backbone/src/main/java/org/researchstack/backbone/onboarding/ReConsentInstructionStep.java
new file mode 100644
index 000000000..fbe3051d2
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/onboarding/ReConsentInstructionStep.java
@@ -0,0 +1,18 @@
+package org.researchstack.backbone.onboarding;
+
+import org.researchstack.backbone.step.InstructionStep;
+
+/**
+ * Created by TheMDP on 3/25/17.
+ */
+
+public class ReConsentInstructionStep extends InstructionStep {
+ /* Default constructor needed for serialization/deserialization of object */
+ public ReConsentInstructionStep() {
+ super();
+ }
+
+ public ReConsentInstructionStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/AudioResult.java b/backbone/src/main/java/org/researchstack/backbone/result/AudioResult.java
new file mode 100644
index 000000000..6d1086e83
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/AudioResult.java
@@ -0,0 +1,39 @@
+package org.researchstack.backbone.result;
+
+import java.io.File;
+
+/**
+ * Created by TheMDP on 2/26/17.
+ */
+
+public class AudioResult extends FileResult {
+
+ /**
+ * A value from 0.0 - 1.0
+ * The rolling average of the audio data,
+ * can be used to get an overall picture of the audio's background noise
+ * For instance, you can not allow the user to do a step if this is > 0.45
+ */
+ private double rollingAverageOfVolume;
+
+ /* Default identifier for serialization/deserialization */
+ AudioResult() {
+ super();
+ }
+
+ public AudioResult(String identifier) {
+ super(identifier);
+ }
+
+ public AudioResult(String identifier, File file, String contentType) {
+ super(identifier, file, contentType);
+ }
+
+ public double getRollingAverageOfVolume() {
+ return rollingAverageOfVolume;
+ }
+
+ public void setRollingAverageOfVolume(double rollingAverageOfVolume) {
+ this.rollingAverageOfVolume = rollingAverageOfVolume;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/FileResult.java b/backbone/src/main/java/org/researchstack/backbone/result/FileResult.java
new file mode 100644
index 000000000..fecbf7aca
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/FileResult.java
@@ -0,0 +1,83 @@
+package org.researchstack.backbone.result;
+
+import java.io.File;
+
+/**
+ * Created by TheMDP on 2/6/17.
+ *
+ * The `FileResult` class is a result that references the location of a file produced
+ * during a task.
+ *
+ * A file result is typically generated by the library as the task proceeds. When the task
+ * completes, it may be appropriate to serialize the linked file for transmission
+ * to the server.
+ *
+ * Active steps typically produce file results when Accelerometer or Gyroscope sensor data is
+ * serialized to disk using a data logger (`DataLogger`). Audio recording also produces a file
+ * result.
+ *
+ * When you write a custom step, use files to report results only when the data
+ * is likely to be too big to hold in memory for the duration of the task. For
+ * example, fitness tasks that use sensors can be quite long and can generate
+ * a large number of samples. To compensate for the length of the task, you can stream the samples to disk during
+ * the task, and return a `FileResult` object in the result hierarchy, usually as a
+ * child of an `StepResult` object.
+ */
+
+public class FileResult extends Result {
+
+ /**
+ * The MIME content type of the result.
+ *
+ * For example, "application/json".
+ */
+ private String contentType;
+
+ /**
+ * The File location of the file produced.
+ *
+ * It is the responsibility of the receiver of the result object to delete
+ * the file when it is no longer needed.
+ *
+ * The file is typically written to the output directory of the
+ * ViewTaskActivity, so it is common to manage the archiving or cleanup
+ * of these files by archiving or deleting the entire output directory.
+ */
+ private File file;
+
+ /* Default identifier for serialization/deserialization */
+ FileResult() {
+ super();
+ }
+
+ public FileResult(String identifier) {
+ super(identifier);
+ }
+
+ /**
+ * @param identifier identifier for this FileResult
+ * @param file the location of the file
+ * @param contentType the mime content type of the file
+ */
+ public FileResult(String identifier, File file, String contentType) {
+ super(identifier);
+ setFile(file);
+ setContentType(contentType);
+ }
+
+ public String getContentType() {
+ return contentType;
+ }
+
+ public void setContentType(String contentType) {
+ this.contentType = contentType;
+ }
+
+ public File getFile() {
+ return file;
+ }
+
+ public void setFile(File file) {
+ this.file = file;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/Result.java b/backbone/src/main/java/org/researchstack/backbone/result/Result.java
index b64ff5571..c5f62dc2f 100644
--- a/backbone/src/main/java/org/researchstack/backbone/result/Result.java
+++ b/backbone/src/main/java/org/researchstack/backbone/result/Result.java
@@ -1,5 +1,7 @@
package org.researchstack.backbone.result;
+import org.researchstack.backbone.utils.ObjectUtils;
+
import java.io.Serializable;
import java.util.Date;
@@ -22,14 +24,16 @@
* subclass.
*/
public class Result implements Serializable {
- private String identifier;
+ String identifier;
private Date startDate;
private Date endDate;
- // unimplemented but exists in RK, implement or delete if not needed
- private boolean saveable;
+ /* Default identifier for serialization/deserialization */
+ Result() {
+ super();
+ }
/**
* Returns an initialized result using the specified identifier.
@@ -97,4 +101,23 @@ public void setEndDate(Date endDate) {
this.endDate = endDate;
}
+ /**
+ * @param newIdentifier new identifier for result
+ * @return a deep copy of this object, and its polymorphism, with a new identifier set
+ */
+ public Result deepCopy(String newIdentifier) {
+ Result copy = (Result)ObjectUtils.clone(this);
+ copy.identifier = newIdentifier;
+ return copy;
+ }
+
+ @Override
+ public String toString() {
+ final StringBuffer sb = new StringBuffer("Result{");
+ sb.append("identifier='").append(identifier).append('\'');
+ sb.append(", startDate=").append(startDate);
+ sb.append(", endDate=").append(endDate);
+ sb.append('}');
+ return sb.toString();
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/StepResult.java b/backbone/src/main/java/org/researchstack/backbone/result/StepResult.java
index 603beb1db..095010aaf 100644
--- a/backbone/src/main/java/org/researchstack/backbone/result/StepResult.java
+++ b/backbone/src/main/java/org/researchstack/backbone/result/StepResult.java
@@ -3,9 +3,14 @@
import org.researchstack.backbone.answerformat.AnswerFormat;
import org.researchstack.backbone.step.QuestionStep;
import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.utils.ObjectUtils;
+import java.io.Serializable;
+import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
/**
@@ -29,6 +34,12 @@ public class StepResult extends Result {
private AnswerFormat answerFormat;
+ /* Default identifier for serilization/deserialization */
+ StepResult() {
+ super();
+ this.results = new LinkedHashMap<>();
+ }
+
/**
* Creates a StepResult from a {@link Step}.
*
@@ -39,7 +50,7 @@ public class StepResult extends Result {
*/
public StepResult(Step step) {
super(step.getIdentifier());
- this.results = new HashMap<>();
+ this.results = new LinkedHashMap<>();
if (step instanceof QuestionStep) {
answerFormat = ((QuestionStep) step).getAnswerFormat();
@@ -108,4 +119,16 @@ public void setResultForIdentifier(String identifier, T result) {
public AnswerFormat getAnswerFormat() {
return answerFormat;
}
+
+ @Override
+ public String toString() {
+ final StringBuffer sb = new StringBuffer("StepResult{");
+ sb.append(", identifier='").append(identifier).append('\'');
+ sb.append(", answerFormat=").append(answerFormat);
+ sb.append(", startDate=").append(getStartDate());
+ sb.append(", endDate=").append(getIdentifier());
+ sb.append("results=").append(results);
+ sb.append('}');
+ return sb.toString();
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/TappingIntervalResult.java b/backbone/src/main/java/org/researchstack/backbone/result/TappingIntervalResult.java
new file mode 100644
index 000000000..acf6c3b61
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/TappingIntervalResult.java
@@ -0,0 +1,151 @@
+package org.researchstack.backbone.result;
+
+import com.google.gson.annotations.SerializedName;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Created by TheMDP on 2/23/17.
+ */
+
+public class TappingIntervalResult extends Result {
+
+ /**
+ * An array of collected samples, in which each item is an `ORKTappingSample` object that represents a
+ * tapping event.
+ */
+ @SerializedName("TappingSamples")
+ private List samples;
+
+ /**
+ * The size of the bounds of the step view containing the tap targets.
+ */
+ @SerializedName("TappingViewSize")
+ private String stepViewSize;
+
+ /**
+ * The frame of the left button, in points, relative to the step view bounds.
+ */
+ @SerializedName("ButtonRectLeft")
+ private String buttonRectLeft;
+
+ /**
+ * sThe frame of the right button, in points, relative to the step view bounds.
+ */
+ @SerializedName("ButtonRectRight")
+ private String buttonRectRight;
+
+ /* Default identifier for serialization/deserialization */
+ TappingIntervalResult() {
+ super();
+ }
+
+ public TappingIntervalResult(String identifier) {
+ super(identifier);
+ }
+
+ public List getSamples() {
+ return samples;
+ }
+
+ public void setSamples(List samples) {
+ this.samples = samples;
+ }
+
+ public void setStepViewSize(int width, int height) {
+ this.stepViewSize = String.format(Locale.getDefault(), "{%d, %d}", width, height);
+ }
+
+ public void setButtonRect1(int x, int y, int width, int height) {
+ // This is the output format of iOS' NSStringFromCGRect, which is the expected format
+ this.buttonRectLeft = String.format(Locale.getDefault(), "{{%d, %d} {%d, %d}}", x, y, width, height);
+ }
+
+ public void setButtonRect2(int x, int y, int width, int height) {
+ // This is the output format of iOS' NSStringFromCGRect, which is the expected format
+ this.buttonRectRight = String.format(Locale.getDefault(), "{{%d, %d} {%d, %d}}", x, y, width, height);
+ }
+
+ public static class Sample implements Serializable {
+ /**
+ * A relative timestamp indicating the time of the tap event.
+ *
+ * The timestamp is relative to the value of `startDate` in the `Result` object that includes this
+ * sample.
+ */
+ @SerializedName("TapTimeStamp")
+ private long timestamp;
+
+ /**
+ * A duration of the tap event.
+ *
+ * The duration store time interval between touch down and touch release events.
+ */
+ @SerializedName("duration")
+ private long duration;
+
+ /**
+ * An enumerated value that indicates which button was tapped, if any.
+ *
+ * If the value of this property is `ORKTappingButtonIdentifierNone`, it indicates that the tap
+ * was near, but not inside, one of the target buttons.
+ */
+ @SerializedName("TappedButtonId")
+ private TappingButtonIdentifier buttonIdentifier;
+
+ /**
+ * The location of the tap within the step's view.
+ *
+ * The location coordinates are relative to a rectangle whose size corresponds to
+ * the `stepViewSize` in the enclosing `ORKTappingIntervalResult` object.
+ */
+ @SerializedName("TapCoordinate")
+ private String location;
+
+ /* Default identifier for serialization/deserialization */
+ public Sample() {
+ super();
+ }
+
+ public long getTimestamp() {
+ return timestamp;
+ }
+
+ public void setTimestamp(long timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ public long getDuration() {
+ return duration;
+ }
+
+ public void setDuration(long duration) {
+ this.duration = duration;
+ }
+
+ public TappingButtonIdentifier getButtonIdentifier() {
+ return buttonIdentifier;
+ }
+
+ public void setButtonIdentifier(TappingButtonIdentifier buttonIdentifier) {
+ this.buttonIdentifier = buttonIdentifier;
+ }
+ public void setLocation(int x, int y) {
+ this.location = String.format(Locale.getDefault(), "{%d, %d}", x, y);
+ }
+ }
+
+ /**
+ Values that identify the button that was tapped in a tapping sample.
+ */
+ public enum TappingButtonIdentifier {
+ // The touch landed outside of the two buttons.
+ TappedButtonNone,
+ // The touch landed in the left button.
+ TappedButtonLeft,
+ // The touch landed in the right button.
+ TappedButtonRight;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/TaskResult.java b/backbone/src/main/java/org/researchstack/backbone/result/TaskResult.java
index f7abc5249..0988249f8 100644
--- a/backbone/src/main/java/org/researchstack/backbone/result/TaskResult.java
+++ b/backbone/src/main/java/org/researchstack/backbone/result/TaskResult.java
@@ -2,7 +2,15 @@
import android.net.Uri;
+import org.researchstack.backbone.step.QuestionStep;
+import org.researchstack.backbone.step.Step;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -17,18 +25,34 @@
*
* The results property contains the step results for the task.
*/
-public class TaskResult extends Result {
+public class TaskResult extends Result
+{
private Map results;
- // unimplemented but exists in RK, implement or delete if not needed
- private UUID uuidTask;
+ /** Store extra details needed for parsing the result in the taskDetails */
+ private Map taskDetails;
- // unimplemented but exists in RK, implement or delete if not needed
- private Uri outputDirectory;
+ /* Default identifier for serilization/deserialization */
+ TaskResult() {
+ super();
+ this.results = new LinkedHashMap<>();
+ this.taskDetails = new HashMap<>();
+ }
- public TaskResult(String identifier) {
+ public TaskResult(String identifier)
+ {
super(identifier);
- this.results = new HashMap<>();
+ this.results = new LinkedHashMap<>();
+ this.taskDetails = new HashMap<>();
+ }
+
+ /**
+ * Set the Map of all of the StepResults in the task.
+ * @param newResults set the results object
+ */
+ public void setResults(Map newResults)
+ {
+ results = newResults;
}
/**
@@ -36,7 +60,8 @@ public TaskResult(String identifier) {
*
* @return a Map of the StepResults
*/
- public Map getResults() {
+ public Map getResults()
+ {
return results;
}
@@ -44,9 +69,10 @@ public Map getResults() {
* Returns a step result for the specified step identifier, if one exists.
*
* @param identifier The identifier for which to search.
- * @return The result for the specified step, or {@link nil} for none.
+ * @return The result for the specified step, or null for none.
*/
- public StepResult getStepResult(String identifier) {
+ public StepResult getStepResult(String identifier)
+ {
return results.get(identifier);
}
@@ -59,4 +85,12 @@ public StepResult getStepResult(String identifier) {
public void setStepResultForStepIdentifier(String identifier, StepResult stepResult) {
results.put(identifier, stepResult);
}
-}
+
+ public Map getTaskDetails() {
+ return taskDetails;
+ }
+
+ public void setTaskDetails(Map taskDetails) {
+ this.taskDetails = taskDetails;
+ }
+}
\ No newline at end of file
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/TimedWalkResult.java b/backbone/src/main/java/org/researchstack/backbone/result/TimedWalkResult.java
new file mode 100644
index 000000000..aa279957c
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/TimedWalkResult.java
@@ -0,0 +1,55 @@
+package org.researchstack.backbone.result;
+
+/**
+ * Created by TheMDP on 2/22/17.
+ */
+
+public class TimedWalkResult extends Result {
+ /**
+ The timed walk distance in meters.
+ */
+ private double distanceInMeters;
+
+ /**
+ The time limit to complete the trials.
+ */
+ private int timeLimit;
+
+ /**
+ The trial duration (that is, the time taken to do the walk).
+ */
+ private int duration;
+
+ /* Default identifier for serilization/deserialization */
+ TimedWalkResult() {
+ super();
+ }
+
+ public TimedWalkResult(String identifier) {
+ super(identifier);
+ }
+
+ public double getDistanceInMeters() {
+ return distanceInMeters;
+ }
+
+ public void setDistanceInMeters(double distanceInMeters) {
+ this.distanceInMeters = distanceInMeters;
+ }
+
+ public int getTimeLimit() {
+ return timeLimit;
+ }
+
+ public void setTimeLimit(int timeLimit) {
+ this.timeLimit = timeLimit;
+ }
+
+ public int getDuration() {
+ return duration;
+ }
+
+ public void setDuration(int duration) {
+ this.duration = duration;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLogger.java b/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLogger.java
new file mode 100644
index 000000000..b269ea644
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLogger.java
@@ -0,0 +1,133 @@
+package org.researchstack.backbone.result.logger;
+
+import org.researchstack.backbone.utils.LogExt;
+
+import java.io.File;
+import java.io.UnsupportedEncodingException;
+
+/**
+ * Created by TheMDP on 2/6/17.
+ *
+ * The DataLogger class can be used to stream data to a file on another thread
+ * It should only be used when the data may become too large to store in app memory
+ */
+
+public class DataLogger {
+
+ protected static final String UTF_8 = "UTF-8";
+
+ /**
+ * The file that the data is being written to
+ */
+ private File file;
+
+ /**
+ * The Thread that performs the file writing
+ */
+ private DataLoggerFileWriterThread dataLoggerWriterThread;
+ private DataWriteListener dataWriteListener;
+
+ public DataLogger(File file, DataWriteListener listener) {
+ this.file = file;
+ this.dataWriteListener = listener;
+ }
+
+ /**
+ * @param fileHeader the header of the file to write
+ * @param fileFooter the footer of the file to write
+ */
+ public void start(String fileHeader, String fileFooter) {
+ if (dataLoggerWriterThread != null) {
+ throw new IllegalStateException("Thread was started while another was running, " +
+ "check your application logic, because this is not allowed");
+ }
+
+ dataLoggerWriterThread = new DataLoggerFileWriterThread(
+ file, fileHeader, fileFooter,
+ new DataWriteListener()
+ {
+ @Override
+ public void onWriteError(Throwable throwable) {
+ dataLoggerFailed(throwable);
+ }
+
+ @Override
+ public void onWriteComplete(File file) {
+ DataLoggerManager.getInstance().dataLoggerTaskFinished(DataLogger.this, null);
+ dataWriteListener.onWriteComplete(file);
+ dataLoggerWriterThread = null;
+ }
+ });
+
+ DataLoggerManager.getInstance().startNewDataLoggerTask(this);
+ dataLoggerWriterThread.start();
+ }
+
+ /**
+ * Call when you are done writing to the data logger
+ */
+ public void stop() {
+ if (dataLoggerWriterThread == null) {
+ throw new IllegalStateException("You need to call start() first");
+ }
+ dataLoggerWriterThread.stop();
+ }
+
+ /**
+ * Call when you want the data logger file to immediately stop and the file deleted
+ */
+ public void cancel() {
+ if (dataLoggerWriterThread != null) {
+ dataLoggerWriterThread.cancel();
+ }
+ }
+
+ /**
+ * Cancels the data logger because of an error that happened with a class using this class
+ * @param throwable the error to return to the listener, that happened above this class
+ */
+ public void cancelDueToError(Throwable throwable) {
+ dataLoggerWriterThread.cancel();
+ dataLoggerFailed(throwable);
+ }
+
+ private void dataLoggerFailed(Throwable throwable) {
+ LogExt.e(getClass(), "Data logger failed ", throwable);
+ DataLoggerManager.getInstance().dataLoggerTaskFinished(DataLogger.this, throwable);
+ dataWriteListener.onWriteError(throwable);
+ dataLoggerWriterThread = null;
+ }
+
+ /**
+ * @param data to append to the file on a different async task thread
+ */
+ public void appendData(byte[] data) {
+ if (dataLoggerWriterThread != null) {
+ dataLoggerWriterThread.appendData(data);
+ }
+ }
+
+ /**
+ * @param data string byte data to append to the file on a different async task thread
+ */
+ public void appendData(String data) {
+ try {
+ appendData(data.getBytes(UTF_8));
+ } catch (UnsupportedEncodingException e) {
+ dataWriteListener.onWriteError(e);
+ }
+ }
+
+ public File getFile() {
+ return file;
+ }
+
+ public void setFile(File file) {
+ this.file = file;
+ }
+
+ public interface DataWriteListener {
+ void onWriteError(Throwable throwable);
+ void onWriteComplete(File file);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLoggerFileWriterThread.java b/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLoggerFileWriterThread.java
new file mode 100644
index 000000000..d707597f7
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLoggerFileWriterThread.java
@@ -0,0 +1,227 @@
+package org.researchstack.backbone.result.logger;
+
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.os.Message;
+import androidx.annotation.MainThread;
+
+import org.researchstack.backbone.utils.LogExt;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Created by TheMDP on 2/9/17.
+ *
+ * The DataLoggerFileWriterThread uses a HandlerThread to send byte[] data to another thread
+ * which writes it to a file asynchronously
+ */
+
+public class DataLoggerFileWriterThread {
+
+ private static final String BUNDLE_KEY_BYTE_DATA = "bytedata";
+
+ private static final int MSG_WRITE_REQUEST = 1;
+ private static final int MSG_STOP = 2;
+ private static final int MSG_CANCEL = 3;
+
+ /**
+ * File output stream to write to the file
+ * this object SHOULD NOT be touched on the main thread, and only from within the Handler
+ */
+ private FileOutputStream fileOutputStream;
+
+ /**
+ * The listener for this class, will only be called from the main thread
+ */
+ private final DataLogger.DataWriteListener writeListener;
+
+ private final File file;
+ private final String fileHeader;
+ private final String fileFooter;
+
+ private HandlerThread thread;
+ private FileWriterHandler threadHandler;
+ private Handler mainHandler;
+
+ protected DataLoggerFileWriterThread(
+ File file,
+ String fileHeader,
+ String fileFooter,
+ DataLogger.DataWriteListener listener)
+ {
+ this.file = file;
+ this.fileHeader = fileHeader;
+ this.fileFooter = fileFooter;
+ this.writeListener = listener;
+ }
+
+ private class FileWriterHandler extends Handler {
+
+ private FileWriterHandler(Looper looper) {
+ super(looper);
+ }
+ private final AtomicBoolean isStopped = new AtomicBoolean(false);
+
+ @Override
+ public void handleMessage(Message msg) {
+ try {
+ switch (msg.what) {
+ case MSG_WRITE_REQUEST:
+ if (isStopped.get()) {
+ LogExt.w(getClass(), "Data write request received after stopped or cancelled");
+ return;
+ }
+
+ openFileStreamIfNull();
+
+ Bundle bundle = msg.getData();
+ if (bundle != null) {
+ byte[] bytesToWrite = bundle.getByteArray(BUNDLE_KEY_BYTE_DATA);
+ if (bytesToWrite != null) {
+ fileOutputStream.write(bytesToWrite);
+ }
+ }
+
+ break;
+ case MSG_STOP:
+ if (isStopped.get()) {
+ LogExt.w(getClass(), "Stop request received after stopped or cancelled");
+ return;
+ }
+ isStopped.set(true);
+
+ // call openFileStreamIfNull to combat an edge case where no write requests were processed
+ openFileStreamIfNull();
+ closeFileStream();
+ writeCompleteFromThreadToMainThread();
+
+ break;
+ case MSG_CANCEL:
+ if (isStopped.get()) {
+ LogExt.w(getClass(), "Cancel request received after stopped or cancelled");
+ return;
+ }
+ isStopped.set(true);
+
+ closeFileStream();
+ writeCanceledFromThreadToMainThread();
+
+ break;
+ }
+ } catch (final IOException e) {
+ if (fileOutputStream != null) {
+ try {
+ fileOutputStream.close();
+ } catch (IOException closingException) {
+ closingException.printStackTrace();
+ }
+ }
+ if (e.getMessage().contains("Stream Closed")) {
+ // Ignore this IOException, since the message can just be ignored safely
+ } else {
+ writeFailedFromThreadToMainThread(e);
+ }
+ }
+ }
+
+ private void openFileStreamIfNull() throws IOException {
+ if (!file.exists()) {
+ file.createNewFile();
+ }
+ if (fileOutputStream == null) {
+ fileOutputStream = new FileOutputStream(file, true);
+ // Write fileHeader if there is one
+ if (fileHeader != null) {
+ fileOutputStream.write(fileHeader.getBytes(DataLogger.UTF_8));
+ }
+ }
+ }
+
+ private void closeFileStream() throws IOException {
+ if (fileOutputStream != null) {
+ if (fileFooter != null) {
+ fileOutputStream.write(fileFooter.getBytes(DataLogger.UTF_8));
+ }
+ fileOutputStream.close();
+ }
+ }
+ }
+
+ @MainThread
+ protected void start() {
+ if (thread != null) {
+ throw new IllegalStateException("Cannot call start while thread is running");
+ }
+
+
+ thread = new HandlerThread(file.getName());
+ mainHandler = new Handler();
+ thread.start();
+ threadHandler = new FileWriterHandler(thread.getLooper());
+ }
+
+ /**
+ * Will stop the thread which will soon after call the listener's complete callback
+ */
+ @MainThread
+ protected void stop() {
+ threadHandler.sendEmptyMessage(MSG_STOP);
+ }
+
+ @MainThread
+ protected void cancel() {
+ threadHandler.sendEmptyMessage(MSG_CANCEL);
+ }
+
+ // Only should be called from thread handler
+ private void writeFailedFromThreadToMainThread(final Throwable throwable) {
+ thread.quit();
+ mainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ writeListener.onWriteError(throwable);
+ }
+ });
+ }
+
+ // Only should be called from thread handler
+ private void writeCompleteFromThreadToMainThread() {
+ mainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ thread.quit();
+ writeListener.onWriteComplete(file);
+ }
+ });
+ }
+
+ // Only should be called from thread handler
+ private void writeCanceledFromThreadToMainThread() {
+ mainHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ thread.quit();
+ boolean success = file.delete();
+ if (!success) {
+ LogExt.d(getClass(), "Failed to delete file " + file.toString());
+ }
+ }
+ });
+ }
+
+ /**
+ * @param data append data to the file, which will be sent to the thread for writing
+ */
+ protected void appendData(byte[] data) {
+ final Bundle bundle = new Bundle();
+ bundle.putByteArray(BUNDLE_KEY_BYTE_DATA, data);
+ Message message = Message.obtain(threadHandler, MSG_WRITE_REQUEST);
+ message.setData(bundle);
+ message.sendToTarget();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLoggerManager.java b/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLoggerManager.java
new file mode 100644
index 000000000..3aa8d4c0f
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/result/logger/DataLoggerManager.java
@@ -0,0 +1,212 @@
+package org.researchstack.backbone.result.logger;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.annotation.MainThread;
+import android.util.Log;
+
+import com.google.gson.Gson;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Created by TheMDP on 2/7/17.
+ *
+ * The DataLoggerManager class is central station for the DataLogger file's life cycle
+ *
+ * It works by associating a "FileStatus" with each DataLogger file that is created
+ * The FileStatus can be "dirty", which means it is either being created or has not reached
+ * the stage of attempted upload. Once the FileStatus is attempted upload, it will exist
+ * until it is uploaded. After it is uploaded, the necessary parts of the file needed by
+ * the app can be stored, but the underlying data will be removed.
+ */
+
+public class DataLoggerManager {
+
+ /**
+ * SharedPreferences are used to keep track of the DataLogger files' status
+ * They can be dirty while being written, completed, and uploaded
+ */
+ private SharedPreferences sharedPrefs;
+ private static final String SHARED_PREFS_KEY = "DataLoggerManagerSharedPrefs";
+ private Gson gson; // used to serialize/deserialize DataLoggerFileStatus
+
+ private static DataLoggerManager instance;
+
+ @MainThread
+ public static DataLoggerManager getInstance() {
+ if (instance == null) {
+ throw new IllegalStateException("DataLoggerManager must be initialized with context before it can be accessed");
+ }
+ return instance;
+ }
+
+ /**
+ * @return true if DataLoggerManager is initialized with context, false if not and it needs to be
+ */
+ public static boolean isInitialized() {
+ return instance != null;
+ }
+
+ @MainThread
+ public static void initialize(Context context) {
+ if (instance == null) {
+ instance = new DataLoggerManager(context);
+ }
+ }
+
+ private DataLoggerManager(Context context) {
+ sharedPrefs = context.getSharedPreferences(SHARED_PREFS_KEY, Context.MODE_PRIVATE);
+ gson = new Gson();
+ }
+
+ @MainThread
+ protected void startNewDataLoggerTask(DataLogger dataLogger) {
+ createNewDataLoggerFileStatus(dataLogger.getFile());
+ }
+
+ @MainThread
+ protected void dataLoggerTaskFinished(DataLogger dataLogger, Throwable error) {
+ if (error != null) {
+ deleteFileStatus(dataLogger.getFile());
+ }
+ }
+
+ /**
+ * This creates a new status for the data logger that is is writing and considered "dirty"
+ * @param file to operate upon
+ */
+ private void createNewDataLoggerFileStatus(File file) {
+ DataLoggerFileStatus fileStatus = new DataLoggerFileStatus(
+ fullFilePathAndName(file), true, false);
+ String sharedPrefsKey = fileStatus.getSharedPrefsKey();
+ String statusJson = gson.toJson(fileStatus);
+ sharedPrefs.edit().putString(sharedPrefsKey, statusJson).apply();
+ }
+
+ /**
+ * This method deletes the file and also deletes it's status
+ * @param file to operate upon
+ */
+ public void deleteFileStatus(File file) {
+ DataLoggerFileStatus fileStatus = new DataLoggerFileStatus(
+ fullFilePathAndName(file), true, false);
+ String sharedPrefsKey = fileStatus.getSharedPrefsKey();
+
+ sharedPrefs.edit().remove(sharedPrefsKey).apply();
+ boolean success = file.delete();
+
+ if (!success) {
+ Log.e(getClass().getCanonicalName(), "Failed to delete data logger file");
+ }
+ }
+
+ /**
+ * This will loop through all the data logger files that exist in storage and
+ * remove any that are "dirty". A "dirty" file is any file that has not been zipped
+ * up and attempted to be uploaded yet.
+ *
+ * Examples of dirty data logger files are ones that were written before and app crashed
+ * or was force closed, or files that were written and then the user cancelled the active task
+ * that was responsible for creating them
+ */
+ public void deleteAllDirtyFiles() {
+ Map fileStatusMap = sharedPrefs.getAll();
+
+ List prefKeysToDelete = new ArrayList<>();
+ List filePathsToDelete = new ArrayList<>();
+
+ // Loop through all the potential file status and find ones that are dirty
+ for (String key : fileStatusMap.keySet()) {
+ Object fileStatusObj = fileStatusMap.get(key);
+ if (fileStatusObj instanceof String) {
+ DataLoggerFileStatus fileStatus = gson.fromJson((String)fileStatusObj, DataLoggerFileStatus.class);
+ if (fileStatus.isDirty) {
+ prefKeysToDelete.add(key);
+ filePathsToDelete.add(fileStatus.filePath);
+ }
+ }
+ }
+
+ // remove all dirty files
+ for (String filePath : filePathsToDelete) {
+ File file = new File(filePath);
+ if (file.exists()) {
+ boolean success = file.delete();
+ if (!success) {
+ Log.e(getClass().getCanonicalName(), "Failed to delete dirty file " + filePath);
+ }
+ }
+ }
+
+ // remove dirty shared prefs
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+ for (String prefsKey : prefKeysToDelete) {
+ editor.remove(prefsKey);
+ }
+ editor.apply();
+ }
+
+ /**
+ * Removes the "dirty" status from the files, as they are now complete and should
+ * be kept around until they are successfully uploaded
+ * @param fileList list of files to change status of
+ */
+ public void updateFileListToAttemptedUploadStatus(List fileList) {
+ Map fileStatusMap = sharedPrefs.getAll();
+ Map updateMap = new HashMap<>();
+ // Loop through all the potential file status and find ones that are dirty
+ for (String key : fileStatusMap.keySet()) {
+ Object fileStatusObj = fileStatusMap.get(key);
+ if (fileStatusObj instanceof String) {
+ DataLoggerFileStatus fileStatus = gson.fromJson((String)fileStatusObj, DataLoggerFileStatus.class);
+ for (File file : fileList) {
+ String filePath = fullFilePathAndName(file);
+ if (fileStatus.filePath.equals(filePath)) {
+ DataLoggerFileStatus updateStatus = new DataLoggerFileStatus(filePath, false, false);
+ updateMap.put(updateStatus.getSharedPrefsKey(), gson.toJson(updateStatus));
+ }
+ }
+ }
+ }
+
+ // Update the shared pref refs
+ SharedPreferences.Editor editor = sharedPrefs.edit();
+ for (String prefsKey : updateMap.keySet()) {
+ editor.putString(prefsKey, updateMap.get(prefsKey));
+ }
+ editor.apply();
+ }
+
+ private String fullFilePathAndName(File file) {
+ return file.getAbsolutePath() + file.getName();
+ }
+
+ /**
+ * Used to keep track of all the DataLogger files and their status
+ */
+ protected class DataLoggerFileStatus {
+ protected boolean isDirty;
+ protected boolean isUploaded;
+ protected String filePath;
+
+ DataLoggerFileStatus() {}
+
+ DataLoggerFileStatus(String filePath, boolean isDirty, boolean isUploaded) {
+ this.filePath = filePath;
+ this.isDirty = isDirty;
+ this.isUploaded = isUploaded;
+ }
+
+ /**
+ * @return a unique value that can be used to safely store this class in SharedPreferences
+ */
+ protected String getSharedPrefsKey() {
+ return String.valueOf(filePath.hashCode());
+ }
+ }
+}
diff --git a/skin/src/main/java/org/researchstack/skin/schedule/ScheduleHelper.java b/backbone/src/main/java/org/researchstack/backbone/schedule/ScheduleHelper.java
similarity index 94%
rename from skin/src/main/java/org/researchstack/skin/schedule/ScheduleHelper.java
rename to backbone/src/main/java/org/researchstack/backbone/schedule/ScheduleHelper.java
index 333dd3f85..6643bca12 100644
--- a/skin/src/main/java/org/researchstack/skin/schedule/ScheduleHelper.java
+++ b/backbone/src/main/java/org/researchstack/backbone/schedule/ScheduleHelper.java
@@ -1,4 +1,4 @@
-package org.researchstack.skin.schedule;
+package org.researchstack.backbone.schedule;
import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/AudioTooLoudStep.java b/backbone/src/main/java/org/researchstack/backbone/step/AudioTooLoudStep.java
new file mode 100644
index 000000000..7f722e2dd
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/AudioTooLoudStep.java
@@ -0,0 +1,79 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.step.active.recorder.AudioRecorder;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.utils.LogExt;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 2/26/17.
+ *
+ * The AudioTooLoudStep is designed to be placed after an ActiveStep with identifier audioStepResultIdentifier
+ * The ActiveStep should also have an AudioRecorder that produces an AudioResult that can be
+ * analyzed in this state to see if this should show to the user that their background noise
+ * and the recording environment they are in, is too loud, and therefore unsatisfactory for
+ * the proceeding ActiveStep with AudioRecorder to collect accurate data
+ */
+
+public class AudioTooLoudStep extends InstructionStep implements NavigableOrderedTask.NavigationSkipRule {
+
+ /**
+ * The step identifier where the AudioResult object will be
+ */
+ private String audioStepResultIdentifier;
+
+ /**
+ * Must be a value from 0.0 - 1.0
+ * Step will be skipped unless the loudness threshold is over this threshold
+ */
+ private double loudnessThreshold;
+
+ private boolean isSkippingStep = false;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ AudioTooLoudStep() {
+ super();
+ }
+
+ public AudioTooLoudStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ }
+
+ @Override
+ public boolean shouldSkipStep(TaskResult result, List additionalTaskResults) {
+ boolean isResultTooLoud = AudioRecorder.getLastTotalSampleAvg() > loudnessThreshold;
+ LogExt.i(getClass(), "Audio is " + (isResultTooLoud ? "" : "not") +
+ " too loud with value of " + AudioRecorder.getLastTotalSampleAvg());
+ isSkippingStep = !isResultTooLoud;
+ return !isResultTooLoud;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+ // Only use the next step id if we didn't skip this step, otherwise, proceed as normal
+ if (!isSkippingStep) {
+ return nextStepIdentifier;
+ } else {
+ isSkippingStep = false; // revert back to original operation
+ return null;
+ }
+ }
+
+ public String getAudioStepResultIdentifier() {
+ return audioStepResultIdentifier;
+ }
+
+ public void setAudioStepResultIdentifier(String audioStepResultIdentifier) {
+ this.audioStepResultIdentifier = audioStepResultIdentifier;
+ }
+
+ public double getLoudnessThreshold() {
+ return loudnessThreshold;
+ }
+
+ public void setLoudnessThreshold(double loudnessThreshold) {
+ this.loudnessThreshold = loudnessThreshold;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/CompletionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/CompletionStep.java
new file mode 100644
index 000000000..ec859285e
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/CompletionStep.java
@@ -0,0 +1,32 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.InstructionStepLayout;
+import org.researchstack.backbone.utils.ResUtils;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ */
+
+public class CompletionStep extends InstructionStep {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public CompletionStep() {
+ super();
+ commonInit();
+ }
+
+ public CompletionStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ commonInit();
+ }
+
+ private void commonInit() {
+ setImage(ResUtils.ANIMATED_CHECK_MARK_DELAYED);
+ setIsImageAnimated(true);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return InstructionStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ConsentDocumentStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentDocumentStep.java
index a587fe68b..97a6b65dc 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/ConsentDocumentStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentDocumentStep.java
@@ -12,6 +12,11 @@ public class ConsentDocumentStep extends Step {
private String confirmMessage;
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentDocumentStep() {
+ super();
+ }
+
public ConsentDocumentStep(String identifier) {
super(identifier);
}
diff --git a/skin/src/main/java/org/researchstack/skin/step/ConsentQuizEvaluationStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentQuizEvaluationStep.java
similarity index 79%
rename from skin/src/main/java/org/researchstack/skin/step/ConsentQuizEvaluationStep.java
rename to backbone/src/main/java/org/researchstack/backbone/step/ConsentQuizEvaluationStep.java
index 778bd071c..6adc6d4a4 100644
--- a/skin/src/main/java/org/researchstack/skin/step/ConsentQuizEvaluationStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentQuizEvaluationStep.java
@@ -1,10 +1,10 @@
-package org.researchstack.skin.step;
+package org.researchstack.backbone.step;
-import org.researchstack.backbone.step.Step;
-import org.researchstack.skin.R;
-import org.researchstack.skin.model.ConsentQuizModel;
-import org.researchstack.skin.ui.layout.ConsentQuizEvaluationStepLayout;
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.model.ConsentQuizModel;
+import org.researchstack.backbone.ui.layout.ConsentQuizEvaluationStepLayout;
+@Deprecated // use NavigationFormStep or NavigationSubtaskStep instead
public class ConsentQuizEvaluationStep extends Step {
private ConsentQuizModel quizModel;
diff --git a/skin/src/main/java/org/researchstack/skin/step/ConsentQuizQuestionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentQuizQuestionStep.java
similarity index 69%
rename from skin/src/main/java/org/researchstack/skin/step/ConsentQuizQuestionStep.java
rename to backbone/src/main/java/org/researchstack/backbone/step/ConsentQuizQuestionStep.java
index fb844a464..bf5978453 100644
--- a/skin/src/main/java/org/researchstack/skin/step/ConsentQuizQuestionStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentQuizQuestionStep.java
@@ -1,10 +1,10 @@
-package org.researchstack.skin.step;
+package org.researchstack.backbone.step;
-import org.researchstack.backbone.step.Step;
-import org.researchstack.skin.R;
-import org.researchstack.skin.model.ConsentQuizModel;
-import org.researchstack.skin.ui.layout.ConsentQuizQuestionStepLayout;
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.model.ConsentQuizModel;
+import org.researchstack.backbone.ui.layout.ConsentQuizQuestionStepLayout;
+@Deprecated // Use NavigationFormStep or NavigationSubtaskStep instead
public class ConsentQuizQuestionStep extends Step {
private ConsentQuizModel.QuizQuestion question;
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ConsentReviewSubstepListStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentReviewSubstepListStep.java
new file mode 100644
index 000000000..c48c85893
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentReviewSubstepListStep.java
@@ -0,0 +1,26 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.ConsentReviewSubstepListStepLayout;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/16/17.
+ */
+
+public class ConsentReviewSubstepListStep extends SubstepListStep {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentReviewSubstepListStep() {
+ super();
+ }
+
+ public ConsentReviewSubstepListStep(String identifier, List stepList) {
+ super(identifier, stepList);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return ConsentReviewSubstepListStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ConsentSharingStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentSharingStep.java
index ca27ec0d1..8632ba82a 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/ConsentSharingStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentSharingStep.java
@@ -1,5 +1,6 @@
package org.researchstack.backbone.step;
+import org.researchstack.backbone.answerformat.AnswerFormat;
import org.researchstack.backbone.ui.step.body.SingleChoiceQuestionBody;
/**
@@ -7,16 +8,23 @@
* how much they're willing to allow data to be shared after collection.
*/
public class ConsentSharingStep extends QuestionStep {
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentSharingStep() {
+ super();
+ }
public ConsentSharingStep(String identifier) {
super(identifier);
setOptional(false);
}
+ public ConsentSharingStep(String identifier, String title, AnswerFormat format) {
+ super(identifier, title, format);
+ setOptional(false);
+ }
+
@Override
public Class getStepBodyClass() {
return SingleChoiceQuestionBody.class;
}
-
-
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ConsentSignatureStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentSignatureStep.java
index 3cf36e23e..9e491fd74 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/ConsentSignatureStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentSignatureStep.java
@@ -1,5 +1,7 @@
package org.researchstack.backbone.step;
+import org.researchstack.backbone.ui.step.layout.ConsentSignatureStepLayout;
+
/**
* This class represents the final step in the consent process, collecting the signature from the
* study participant.
@@ -7,11 +9,23 @@
public class ConsentSignatureStep extends Step {
private String signatureDateFormat;
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentSignatureStep() {
+ super();
+ setOptional(false);
+ }
+
public ConsentSignatureStep(String identifier) {
super(identifier);
setOptional(false);
}
+ public ConsentSignatureStep(String identifier, String title, String text) {
+ this(identifier);
+ setTitle(title);
+ setText(text);
+ }
+
/**
* Returns the date format string to be used when producing a date string for the PDF or consent
* review.
@@ -37,4 +51,9 @@ public String getSignatureDateFormat() {
public void setSignatureDateFormat(String signatureDateFormat) {
this.signatureDateFormat = signatureDateFormat;
}
+
+ @Override
+ public Class getStepLayoutClass() {
+ return ConsentSignatureStepLayout.class;
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ConsentSubtaskStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentSubtaskStep.java
new file mode 100644
index 000000000..2d75b7abc
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentSubtaskStep.java
@@ -0,0 +1,36 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.DataProvider;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/14/17.
+ */
+
+public class ConsentSubtaskStep extends SubtaskStep implements NavigableOrderedTask.NavigationSkipRule {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ ConsentSubtaskStep() {
+ super();
+ }
+
+ public ConsentSubtaskStep(String identifier) {
+ super(identifier);
+ }
+
+ public ConsentSubtaskStep(String identifier, String title) {
+ super(identifier, title);
+ }
+
+ public ConsentSubtaskStep(String identifier, List steps) {
+ super(identifier, steps);
+ }
+
+ @Override
+ public boolean shouldSkipStep(TaskResult result, List additionalTaskResults) {
+ return DataProvider.getInstance().isConsented();
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ConsentVisualStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ConsentVisualStep.java
index 00ca0d6df..bb24317d8 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/ConsentVisualStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ConsentVisualStep.java
@@ -12,11 +12,27 @@
public class ConsentVisualStep extends Step {
private ConsentSection section;
+ /**
+ * The index of the section within all the visual consent sections
+ */
+ private int sectionIndex;
+ /**
+ * The total count of the visual consent sections
+ */
+ private int sectionCount;
+
@Deprecated
private String nextButtonString;
- public ConsentVisualStep(String identifier) {
+ /* Default constructor needed for serilization/deserialization of object */
+ protected ConsentVisualStep() {
+ super();
+ }
+
+ public ConsentVisualStep(String identifier, int index, int count) {
super(identifier);
+ setSectionIndex(index);
+ setSectionCount(count);
}
@Override
@@ -58,4 +74,20 @@ public String getNextButtonString() {
public void setNextButtonString(String nextButtonString) {
this.nextButtonString = nextButtonString;
}
+
+ public int getSectionIndex() {
+ return sectionIndex;
+ }
+
+ public void setSectionIndex(int sectionIndex) {
+ this.sectionIndex = sectionIndex;
+ }
+
+ public int getSectionCount() {
+ return sectionCount;
+ }
+
+ public void setSectionCount(int sectionCount) {
+ this.sectionCount = sectionCount;
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/EmailVerificationStep.java b/backbone/src/main/java/org/researchstack/backbone/step/EmailVerificationStep.java
new file mode 100644
index 000000000..9a7ad24de
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/EmailVerificationStep.java
@@ -0,0 +1,32 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.EmailVerificationStepLayout;
+
+import java.util.Arrays;
+
+/**
+ * Created by TheMDP on 1/21/17.
+ *
+ * The EmailVerificationStep contains the substep of email verification and also
+ * the substep of registration, so that the user can change their email from this step
+ */
+
+public class EmailVerificationStep extends SubstepListStep {
+ /* Default constructor needed for serilization/deserialization of object */
+ EmailVerificationStep() {
+ super();
+ }
+
+ public EmailVerificationStep(
+ String identifier,
+ EmailVerificationSubStep verifySubstep,
+ RegistrationStep registrationStep)
+ {
+ super(identifier, Arrays.asList(verifySubstep, registrationStep));
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return EmailVerificationStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/EmailVerificationSubStep.java b/backbone/src/main/java/org/researchstack/backbone/step/EmailVerificationSubStep.java
new file mode 100644
index 000000000..c07001386
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/EmailVerificationSubStep.java
@@ -0,0 +1,23 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.EmailVerificationStepLayout;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class EmailVerificationSubStep extends InstructionStep {
+ /* Default constructor needed for serilization/deserialization of object */
+ EmailVerificationSubStep() {
+ super();
+ }
+
+ public EmailVerificationSubStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return EmailVerificationStepLayout.SubStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/FormStep.java b/backbone/src/main/java/org/researchstack/backbone/step/FormStep.java
index 94db97656..d3e224366 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/FormStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/FormStep.java
@@ -1,7 +1,12 @@
package org.researchstack.backbone.step;
-
+import org.researchstack.backbone.answerformat.AnswerFormat;
import org.researchstack.backbone.answerformat.FormAnswerFormat;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.ui.step.layout.FormStepLayout;
+import org.researchstack.backbone.ui.step.layout.SurveyStepLayout;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -16,13 +21,39 @@
* includes a child StepResult object for each form item.
*/
public class FormStep extends QuestionStep {
- private List formSteps;
+ List formSteps;
+
+ private String skipTitle;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public FormStep() {
+ super();
+ }
public FormStep(String identifier, String title, String text) {
super(identifier, title, new FormAnswerFormat());
setText(text);
}
+ public FormStep(String identifier, String title, String text, List steps) {
+ this(identifier, title, text);
+ formSteps = steps;
+ }
+
+ /**
+ * If true, the first question body layout with an edittext will receive focus on load
+ * default is false and nothing will occur
+ */
+ private boolean autoFocusFirstEditText;
+
+ public boolean isAutoFocusFirstEditText() {
+ return autoFocusFirstEditText;
+ }
+
+ public void setAutoFocusFirstEditText(boolean autoFocusFirstEditText) {
+ this.autoFocusFirstEditText = autoFocusFirstEditText;
+ }
+
/**
* Returns the list of items in the form.
*
@@ -39,4 +70,23 @@ public void setFormSteps(QuestionStep... formSteps) {
public void setFormSteps(List formSteps) {
this.formSteps = formSteps;
}
+
+ /**
+ * @return The title of the skip button, default will be localized "Skip"
+ */
+ public String getSkipTitle() {
+ return skipTitle;
+ }
+
+ /**
+ * @param skipTitle The title of the skip button, default will be localized "Skip"
+ */
+ public void setSkipTitle(String skipTitle) {
+ this.skipTitle = skipTitle;
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return FormStepLayout.class;
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/InstructionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/InstructionStep.java
index 8efbec31f..2858d50f9 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/InstructionStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/InstructionStep.java
@@ -1,7 +1,18 @@
package org.researchstack.backbone.step;
+import android.util.Log;
+import android.widget.ImageView;
+
+import org.researchstack.backbone.model.survey.InstructionSurveyItem;
+import org.researchstack.backbone.model.survey.SurveyItem;
+import org.researchstack.backbone.result.StepResult;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
import org.researchstack.backbone.ui.step.layout.InstructionStepLayout;
+import java.util.List;
+import java.util.Map;
+
/**
* An InstructionStep object gives the participant instructions for a task.
*
@@ -9,8 +20,70 @@
* introductory content, instructions in the middle of a task, or a final message at the completion
* of a task.
*/
-public class InstructionStep extends Step {
- public InstructionStep(String identifier, String title, String detailText) {
+public class InstructionStep extends Step implements NavigableOrderedTask.NavigationRule {
+ /*
+ * Additional detailed text to display
+ */
+ String moreDetailText;
+
+ /**
+ Additional text to display for the step in a localized string at the bottom of the view.
+
+ The footnote is displayed in a smaller font below the continue button. It is intended to be used
+ in order to include disclaimer, copyright, etc. that is important to display in the step but
+ should not distract from the main purpose of the step.
+ */
+ String footnote;
+
+ /**
+ An image that provides visual context for the instruction.
+
+ The image is displayed with aspect fit. Depending on the device, the screen area
+ available for this image can vary. For exact
+ metrics, see `ORKScreenMetricIllustrationHeight`.
+ */
+ String image;
+
+ /**
+ * True if this drawable should be loaded using AnimatedVectorDrawableCompat
+ * false, if this drawable should be loaded like any other image
+ */
+ boolean isImageAnimated;
+
+ /**
+ * The duration in between animation repeats in milliseconds
+ */
+ long animationRepeatDuration;
+
+ /**
+ Optional icon image to show above the title and text.
+ */
+ String iconImage;
+
+ /**
+ * Image scale type
+ */
+ public ImageView.ScaleType scaleType;
+
+ /**
+ * Pointer to the next step to show after this one. If nil, then the next step
+ * is determined by the navigation rules setup by NavigableOrderedTask.
+ */
+ String nextStepIdentifier;
+
+ /**
+ * If not null, the InstructionStepLayout will use this class to add a negative skip action
+ * that when pressed, will skip this step to another step specified by SubmitBarNegativeActionSkipRule
+ */
+ private SubmitBarNegativeActionSkipRule submitBarSkipRule;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ public InstructionStep() {
+ super();
+ }
+
+ public InstructionStep(String identifier, String title, String detailText)
+ {
super(identifier, title);
setText(detailText);
setOptional(false);
@@ -20,4 +93,94 @@ public InstructionStep(String identifier, String title, String detailText) {
public Class getStepLayoutClass() {
return InstructionStepLayout.class;
}
+
+ public void setMoreDetailText(String detailText) {
+ moreDetailText = detailText;
+ }
+ public String getMoreDetailText() {
+ return moreDetailText;
+ }
+
+ public void setFootnote(String newFootnote) {
+ footnote = newFootnote;
+ }
+ public String getFootnote() {
+ return footnote;
+ }
+
+ public void setIsImageAnimated(boolean isImageAnimated) {
+ this.isImageAnimated = isImageAnimated;
+ }
+ public boolean getIsImageAnimated() {
+ return isImageAnimated;
+ }
+
+ public void setImage(String newImage) {
+ image = newImage;
+ }
+ public String getImage() {
+ return image;
+ }
+
+ public void setIconImage(String image) {
+ iconImage = image;
+ }
+ public String getIconImage() {
+ return iconImage;
+ }
+
+ public void setNextStepIdentifier(String identifier) {
+ nextStepIdentifier = identifier;
+ }
+ public String getNextStepIdentifier() {
+ return nextStepIdentifier;
+ }
+
+ public void setAnimationRepeatDuration(long animationRepeatDuration) {
+ this.animationRepeatDuration = animationRepeatDuration;
+ }
+ public long getAnimationRepeatDuration() {
+ return animationRepeatDuration;
+ }
+
+ public void setSubmitBarNegativeActionSkipRule(String taskIdentifier, String title, String skipIdentifier) {
+ submitBarSkipRule = new SubmitBarNegativeActionSkipRule(taskIdentifier, title, skipIdentifier);
+ }
+
+ public SubmitBarNegativeActionSkipRule getSubmitBarNegativeActionSkipRule() {
+ return submitBarSkipRule;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+ return nextStepIdentifier;
+ }
+
+ public static class SubmitBarNegativeActionSkipRule {
+
+ public static final String SKIP_RESULT_KEY = "skip";
+
+ private String taskIdentifier;
+ private String title;
+ private String skipToStepIdentifier;
+
+ public SubmitBarNegativeActionSkipRule(String taskIdentifier, String title, String skipToStepIdentifier) {
+ this.taskIdentifier = taskIdentifier;
+ this.title = title;
+ this.skipToStepIdentifier = skipToStepIdentifier;
+ }
+
+ public void onNegativeActionClicked(InstructionStep step, StepResult stepResult) {
+ // Set the next step identifier
+ step.setNextStepIdentifier(skipToStepIdentifier);
+
+ // add a result to this step view controller to mark that the task was skipped
+ Map stepResultMap = stepResult.getResults();
+ stepResultMap.put(SKIP_RESULT_KEY, taskIdentifier);
+ }
+
+ public String getTitle() {
+ return title;
+ }
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/LoginStep.java b/backbone/src/main/java/org/researchstack/backbone/step/LoginStep.java
new file mode 100644
index 000000000..613ee020a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/LoginStep.java
@@ -0,0 +1,30 @@
+package org.researchstack.backbone.step;
+
+
+import org.researchstack.backbone.model.ProfileInfoOption;
+import org.researchstack.backbone.ui.step.layout.LoginStepLayout;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class LoginStep extends ProfileStep {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ protected LoginStep() {
+ super();
+ setAutoFocusFirstEditText(true);
+ }
+
+ public LoginStep(String identifier, String title, String text, List options, List steps) {
+ super(identifier, title, text, options, steps);
+ setAutoFocusFirstEditText(true);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return LoginStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/NavigationExpectedAnswerQuestionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/NavigationExpectedAnswerQuestionStep.java
new file mode 100644
index 000000000..4375134ee
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/NavigationExpectedAnswerQuestionStep.java
@@ -0,0 +1,82 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.answerformat.AnswerFormat;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.utils.StepHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 12/31/16.
+ *
+ * This QuestionStep works by having a specific expected answer that must be correct or incorrect to
+ * move to the skipToStepIdentifier, with the correct/incorrect status depending on skipIfPassed
+ */
+
+public class NavigationExpectedAnswerQuestionStep extends QuestionStep implements NavigableOrderedTask.NavigationRule {
+
+ private static final String LOG_TAG = NavigationExpectedAnswerQuestionStep.class.getCanonicalName();
+
+ String skipToStepIdentifier;
+ boolean skipIfPassed;
+
+ /** Expected answer for this QuestionStep, used by NavigableOrderedTask */
+ private Object expectedAnswer;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ NavigationExpectedAnswerQuestionStep() {
+ super();
+ }
+
+ public NavigationExpectedAnswerQuestionStep(String identifier) {
+ super(identifier);
+ }
+
+ public NavigationExpectedAnswerQuestionStep(String identifier, String title) {
+ super(identifier, title);
+ }
+
+ public NavigationExpectedAnswerQuestionStep(String identifier, String title, AnswerFormat format) {
+ super(identifier, title, format);
+ }
+
+ public String getSkipToStepIdentifier() {
+ return skipToStepIdentifier;
+ }
+
+ public void setSkipToStepIdentifier(String identifier) {
+ skipToStepIdentifier = identifier;
+ }
+
+ public boolean getSkipIfPassed() {
+ return skipIfPassed;
+ }
+
+ public void setSkipIfPassed(boolean skipIfPassed) {
+ this.skipIfPassed = skipIfPassed;
+ }
+
+ /**
+ * @param expectedAnswer the expected answer for this QuestionStep
+ */
+ public void setExpectedAnswer(Object expectedAnswer) {
+ this.expectedAnswer = expectedAnswer;
+ }
+
+ /**
+ * @return expectedAnswer, which is usually null, but used with NavigableOrderedTask reads steps
+ */
+ public Object getExpectedAnswer() {
+ return expectedAnswer;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+ List stepList = new ArrayList<>();
+ stepList.add(this);
+ return StepHelper.navigationFormStepSkipIdentifier(
+ skipToStepIdentifier, skipIfPassed, stepList, result, additionalTaskResults);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/NavigationFormStep.java b/backbone/src/main/java/org/researchstack/backbone/step/NavigationFormStep.java
new file mode 100644
index 000000000..3e3babb6d
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/NavigationFormStep.java
@@ -0,0 +1,70 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.model.survey.FormSurveyItem;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.utils.StepHelper;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class NavigationFormStep extends FormStep implements NavigableOrderedTask.NavigationRule {
+
+ private static final String LOG_TAG = NavigationFormStep.class.getSimpleName();
+
+ private String skipToStepIdentifier;
+ private boolean skipIfPassed;
+ private Object expectedAnswer;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ public NavigationFormStep() {
+ super();
+ }
+
+ public NavigationFormStep(String identifier, String title, String text) {
+ super(identifier, title, text);
+ }
+
+ public NavigationFormStep(String identifier, String title, String text, List steps) {
+ super(identifier, title, text, steps);
+ }
+
+ public String getSkipToStepIdentifier() {
+ return skipToStepIdentifier;
+ }
+
+ public void setSkipToStepIdentifier(String identifier) {
+ skipToStepIdentifier = identifier;
+ }
+
+ public boolean getSkipIfPassed() {
+ return skipIfPassed;
+ }
+
+ public void setSkipIfPassed(boolean skipIfPassed) {
+ this.skipIfPassed = skipIfPassed;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+
+ // Special case SKIP_BUTTON_TAPPED_ACTION_IDENTIFIER will use the skip button being clicked
+ // to trigger a skip to a specific step identifier
+ if (expectedAnswer != null &&
+ expectedAnswer.equals(FormSurveyItem.SKIP_BUTTON_TAPPED_ACTION_IDENTIFIER)) {
+ if (StepHelper.wasFormStepSkipped(this, result)) {
+ return skipToStepIdentifier;
+ }
+ }
+
+ return StepHelper.navigationFormStepSkipIdentifier(
+ skipToStepIdentifier, skipIfPassed, formSteps, result, additionalTaskResults);
+ }
+
+ public void setExpectedAnswer(Object expectedAnswer) {
+ this.expectedAnswer = expectedAnswer;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/NavigationQuestionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/NavigationQuestionStep.java
new file mode 100644
index 000000000..d21a9913d
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/NavigationQuestionStep.java
@@ -0,0 +1,55 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.answerformat.AnswerFormat;
+import org.researchstack.backbone.result.StepResult;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.utils.StepResultHelper;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 2/5/17.
+ *
+ * The NavigationQuestionStep class allows the developer to implement any custom navigation rule
+ * by allowing them to pass in the interface themselves for determining the nextStepIdentifier
+ */
+
+public class NavigationQuestionStep extends QuestionStep implements NavigableOrderedTask.NavigationRule {
+
+ private List customRules;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ NavigationQuestionStep() {
+ super();
+ }
+
+ public NavigationQuestionStep(String identifier) {
+ super(identifier);
+ }
+
+ public NavigationQuestionStep(String identifier, String title) {
+ super(identifier, title);
+ }
+
+ public NavigationQuestionStep(String identifier, String title, AnswerFormat format) {
+ super(identifier, title, format);
+ }
+
+ public void setCustomRules(List customRules) {
+ this.customRules = customRules;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+ if (customRules != null) {
+ for (NavigableOrderedTask.ObjectEqualsNavigationRule rule : customRules) {
+ String nextIdentifier = rule.nextStepIdentifier(result, additionalTaskResults);
+ if (nextIdentifier != null) {
+ return nextIdentifier;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/NavigationSubtaskStep.java b/backbone/src/main/java/org/researchstack/backbone/step/NavigationSubtaskStep.java
new file mode 100644
index 000000000..a90b56236
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/NavigationSubtaskStep.java
@@ -0,0 +1,69 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.task.Task;
+import org.researchstack.backbone.utils.StepHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/3/17.
+ */
+
+public class NavigationSubtaskStep extends SubtaskStep implements NavigableOrderedTask.NavigationRule {
+
+ String skipToStepIdentifier;
+ boolean skipIfPassed;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ NavigationSubtaskStep() {
+ super();
+ }
+
+ public NavigationSubtaskStep(String identifier) {
+ super(identifier);
+ }
+
+ public NavigationSubtaskStep(String identifier, String title) {
+ super(identifier, title);
+ }
+
+ public NavigationSubtaskStep(String identifier, List steps) {
+ super(identifier, steps);
+ }
+
+ public NavigationSubtaskStep(Task task) {
+ super(task);
+ }
+
+ public String getSkipToStepIdentifier() {
+ return skipToStepIdentifier;
+ }
+
+ public void setSkipToStepIdentifier(String identifier) {
+ skipToStepIdentifier = identifier;
+ }
+
+ public boolean getSkipIfPassed() {
+ return skipIfPassed;
+ }
+
+ public void setSkipIfPassed(boolean skipIfPassed) {
+ this.skipIfPassed = skipIfPassed;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+ List stepList = new ArrayList<>();
+ for (String id : result.getResults().keySet()) {
+ Step step = getStepWithIdentifier(id);
+ if (step != null && step instanceof QuestionStep) {
+ stepList.add((QuestionStep) step);
+ }
+ }
+ return StepHelper.navigationFormStepSkipIdentifier(
+ skipToStepIdentifier, skipIfPassed, stepList, result, additionalTaskResults);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/OnboardingCompletionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/OnboardingCompletionStep.java
new file mode 100644
index 000000000..1b1b00e33
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/OnboardingCompletionStep.java
@@ -0,0 +1,25 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.OnboardingCompletionStepLayout;
+
+/**
+ * Created by TheMDP on 1/18/17.
+ */
+
+public class OnboardingCompletionStep extends InstructionStep {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ OnboardingCompletionStep() {
+ super();
+ }
+
+ public OnboardingCompletionStep(String identifier, String title, String detailText)
+ {
+ super(identifier, title, detailText);
+ setOptional(false);
+ }
+
+ public Class getStepLayoutClass() {
+ return OnboardingCompletionStepLayout.class;
+ }
+}
diff --git a/skin/src/main/java/org/researchstack/skin/step/PassCodeCreationStep.java b/backbone/src/main/java/org/researchstack/backbone/step/PassCodeCreationStep.java
similarity index 76%
rename from skin/src/main/java/org/researchstack/skin/step/PassCodeCreationStep.java
rename to backbone/src/main/java/org/researchstack/backbone/step/PassCodeCreationStep.java
index 0e819774e..af8539255 100644
--- a/skin/src/main/java/org/researchstack/skin/step/PassCodeCreationStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/PassCodeCreationStep.java
@@ -1,7 +1,6 @@
-package org.researchstack.skin.step;
+package org.researchstack.backbone.step;
-import org.researchstack.backbone.step.Step;
-import org.researchstack.skin.ui.layout.SignUpPinCodeCreationStepLayout;
+import org.researchstack.backbone.ui.step.layout.SignUpPinCodeCreationStepLayout;
public class PassCodeCreationStep extends Step {
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/PasscodeStep.java b/backbone/src/main/java/org/researchstack/backbone/step/PasscodeStep.java
new file mode 100644
index 000000000..f93a7b41a
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/PasscodeStep.java
@@ -0,0 +1,60 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.FingerprintStepLayout;
+import org.researchstack.backbone.ui.step.layout.PasscodeCreationStepLayout;
+import org.researchstack.backbone.ui.step.layout.SignUpPinCodeCreationStepLayout;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class PasscodeStep extends InstructionStep {
+
+ private boolean useFingerprint = false;
+
+ public int stateOrdinal = - 1;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ PasscodeStep() {
+ super();
+ }
+
+ public PasscodeStep(String identifier, String title, String text) {
+ super(identifier, title, text);
+ }
+
+ public int getStateOrdinal()
+ {
+ return stateOrdinal;
+ }
+
+ public void setStateOrdinal(int stateOrdinal)
+ {
+ this.stateOrdinal = stateOrdinal;
+ }
+
+ /**
+ * @return true if UI will be FingerprintStepLayout, false if UI will be PasscodeCreationStepLayout
+ */
+ public boolean getUseFingerprint()
+ {
+ return useFingerprint;
+ }
+
+ /**
+ * @param useFingerprint true if UI will be FingerprintStepLayout, false if UI will be PasscodeCreationStepLayout
+ */
+ public void setUseFingerprint(boolean useFingerprint)
+ {
+ this.useFingerprint = useFingerprint;
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ if (!useFingerprint) {
+ return PasscodeCreationStepLayout.class;
+ } else {
+ return FingerprintStepLayout.class;
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/PermissionsStep.java b/backbone/src/main/java/org/researchstack/backbone/step/PermissionsStep.java
new file mode 100644
index 000000000..a3cc6f984
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/PermissionsStep.java
@@ -0,0 +1,36 @@
+package org.researchstack.backbone.step;
+
+import android.os.Build;
+
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.ui.step.layout.PermissionStepLayout;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class PermissionsStep extends Step implements NavigableOrderedTask.NavigationSkipRule {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ PermissionsStep() {
+ super();
+ }
+
+ public PermissionsStep(String identifier, String title, String text) {
+ super(identifier, title);
+ setText(text);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return PermissionStepLayout.class;
+ }
+
+ @Override
+ public boolean shouldSkipStep(TaskResult result, List additionalTaskResults) {
+ return android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.M;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ProfileStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ProfileStep.java
new file mode 100644
index 000000000..4e457dcbd
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ProfileStep.java
@@ -0,0 +1,76 @@
+package org.researchstack.backbone.step;
+
+import android.content.Context;
+import androidx.annotation.MainThread;
+
+import org.researchstack.backbone.model.ProfileInfoOption;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+import org.researchstack.backbone.ui.step.layout.ProfileStepLayout;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ *
+ * Registration step will collect a user's information that is needed to sign up with the server
+ * This usually includes name, email, password, etc
+ */
+
+public class ProfileStep extends FormStep {
+
+ private List profileInfoOptions = new ArrayList<>();
+ public void setProfileInfoOptions(List options) {
+ profileInfoOptions = options;
+ }
+ public List getProfileInfoOptions() {
+ return profileInfoOptions;
+ }
+
+ /* Default constructor needed for serilization/deserialization of object */
+ protected ProfileStep() {
+ super();
+ }
+
+ /**
+ * @param identifier ProfileStep identifier
+ * @param title ProfileStep title
+ * @param text ProfileStep text
+ * @param options ProfileInfoOption list that must match up with steps param
+ * @param steps QuestionStep list that must match up with options param
+ */
+ public ProfileStep(
+ String identifier, String title, String text,
+ List options,
+ List steps)
+ {
+ super(identifier, title, text, steps);
+ profileInfoOptions = options;
+ }
+
+ /**
+ * @param context used by the surveyFactory to create QuestionSteps from ProfileInfoOptions
+ * @param surveyFactory if null, the default one will be used
+ * @param identifier ProfileStep identifier
+ * @param title ProfileStep title
+ * @param text ProfileStep text
+ * @param options ProfileInfoOption list to convert into a QuestionStep list for this FormStep subclass
+ * @param alsoAddConfirmPasswordOption if true, a confirm password step will be made with password step
+ */
+ public ProfileStep(
+ Context context,
+ SurveyFactory surveyFactory,
+ String identifier, String title, String text,
+ List options,
+ boolean alsoAddConfirmPasswordOption)
+ {
+ this(identifier, title, text, options,
+ (surveyFactory == null ? new SurveyFactory() : surveyFactory)
+ .createQuestionSteps(context, options, alsoAddConfirmPasswordOption));
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return ProfileStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/QuestionStep.java b/backbone/src/main/java/org/researchstack/backbone/step/QuestionStep.java
index 014541465..64420f938 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/QuestionStep.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/QuestionStep.java
@@ -1,8 +1,14 @@
package org.researchstack.backbone.step;
import org.researchstack.backbone.answerformat.AnswerFormat;
+import org.researchstack.backbone.ui.step.body.StepBody;
import org.researchstack.backbone.ui.step.layout.SurveyStepLayout;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
/**
* The {@link QuestionStep} class is a concrete subclass of {@link Step} that represents a step in
* which a single question is presented to the user.
@@ -25,6 +31,11 @@ public class QuestionStep extends Step {
private String placeholder;
+ /* Default constructor needed for serilization/deserialization of object */
+ public QuestionStep() {
+ super();
+ }
+
/**
* Returns a new question step that includes the specified identifier.
*
@@ -87,7 +98,7 @@ public Class getStepLayoutClass() {
*
* @return the StepBody implementation for this question step.
*/
- public Class> getStepBodyClass() {
+ public Class extends StepBody> getStepBodyClass() {
return answerFormat.getQuestionType().getStepBodyClass();
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/RegistrationStep.java b/backbone/src/main/java/org/researchstack/backbone/step/RegistrationStep.java
new file mode 100644
index 000000000..2dc120a06
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/RegistrationStep.java
@@ -0,0 +1,69 @@
+package org.researchstack.backbone.step;
+
+import android.content.Context;
+
+import org.researchstack.backbone.model.ProfileInfoOption;
+import org.researchstack.backbone.model.survey.factory.SurveyFactory;
+import org.researchstack.backbone.ui.step.layout.RegistrationStepLayout;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/4/17.
+ */
+
+public class RegistrationStep extends ProfileStep {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ RegistrationStep() {
+ super();
+ }
+
+ public RegistrationStep(String identifier, String title, String text, List options, List steps) {
+ super(identifier, title, text, options, steps);
+ }
+
+ /**
+ * @param context used by the surveyFactory to create QuestionSteps from ProfileInfoOptions
+ * @param surveyFactory if null, the default one will be used
+ * @param identifier ProfileStep identifier
+ * @param title ProfileStep title
+ * @param text ProfileStep text
+ */
+ public RegistrationStep(
+ Context context,
+ SurveyFactory surveyFactory,
+ String identifier,
+ String title,
+ String text)
+ {
+ this(context, surveyFactory, identifier, title, text, Arrays.asList(
+ ProfileInfoOption.EMAIL,
+ ProfileInfoOption.PASSWORD), true); // also create a Confirm Password option after Password
+ }
+
+ /**
+ * @param context used by the surveyFactory to create QuestionSteps from ProfileInfoOptions
+ * @param surveyFactory if null, the default one will be used
+ * @param identifier ProfileStep identifier
+ * @param title ProfileStep title
+ * @param text ProfileStep text
+ * @param options ProfileInfoOption list to convert into a QuestionStep list for this FormStep subclass
+ * @param alsoAddConfirmPasswordOption if true, a confirm password step will be made with password step
+ */
+ public RegistrationStep(
+ Context context,
+ SurveyFactory surveyFactory,
+ String identifier, String title, String text,
+ List options,
+ boolean alsoAddConfirmPasswordOption)
+ {
+ super (context, surveyFactory, identifier, title, text, options, alsoAddConfirmPasswordOption);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return RegistrationStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/RequireSystemFeatureStep.java b/backbone/src/main/java/org/researchstack/backbone/step/RequireSystemFeatureStep.java
new file mode 100644
index 000000000..6ca83f7ca
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/RequireSystemFeatureStep.java
@@ -0,0 +1,45 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.RequireSystemFeatureStepLayout;
+
+/**
+ * Created by TheMDP on 2/21/17.
+ *
+ * This step forces the user to turn on a system feature before proceeding in the task
+ * for now it is just GPS, but this could be expanded to cover Wifi, Cellular, NFC, etc
+ */
+
+public class RequireSystemFeatureStep extends InstructionStep {
+
+ private SystemFeature systemFeature;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ RequireSystemFeatureStep() {
+ super();
+ }
+
+ public RequireSystemFeatureStep(SystemFeature systemFeature, String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ this.systemFeature = systemFeature;
+ }
+
+ public SystemFeature getSystemFeature() {
+ return systemFeature;
+ }
+
+ public void setSystemFeature(SystemFeature systemFeature) {
+ this.systemFeature = systemFeature;
+ }
+
+ /**
+ * System hardware feature that must be enabled
+ */
+ public enum SystemFeature {
+ GPS
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return RequireSystemFeatureStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/ShareTheAppStep.java b/backbone/src/main/java/org/researchstack/backbone/step/ShareTheAppStep.java
new file mode 100644
index 000000000..af6b51892
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/ShareTheAppStep.java
@@ -0,0 +1,89 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.ShareTheAppStepLayout;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/26/17.
+ */
+
+public class ShareTheAppStep extends InstructionStep {
+
+ private static final String TWITTER_ID = "twitter";
+ private static final String FACEBOOK_ID = "facebook";
+ private static final String EMAIL_ID = "email";
+ private static final String SMS_ID = "sms";
+
+ private List shareTypeList = new ArrayList<>();
+
+ /* Default constructor needed for serilization/deserialization of object */
+ ShareTheAppStep() {
+ super();
+ }
+
+ /**
+ * Default share type list will be made from all the enum values
+ * @param identifier of step
+ * @param title of step
+ * @param text of step
+ */
+ public ShareTheAppStep(String identifier, String title, String text) {
+ super(identifier, title, text);
+ shareTypeList = Arrays.asList(ShareType.values());
+ }
+
+ /**
+ * @param identifier of step
+ * @param title of step
+ * @param text of step
+ * @param shareTypeList list of share type to be included in step layout
+ */
+ public ShareTheAppStep(String identifier, String title, String text, List shareTypeList) {
+ super(identifier, title, text);
+ this.shareTypeList = shareTypeList;
+ }
+
+ public List getShareTypeList() {
+ return shareTypeList;
+ }
+
+ public void setShareTypeList(List shareTypeList) {
+ this.shareTypeList = shareTypeList;
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return ShareTheAppStepLayout.class;
+ }
+
+ public enum ShareType {
+
+ TWITTER (TWITTER_ID),
+ FACEBOOK(FACEBOOK_ID),
+ EMAIL (EMAIL_ID),
+ SMS (SMS_ID);
+
+ private String identifier;
+
+ ShareType(String identifier) {
+ this.identifier = identifier;
+ }
+
+ public static List toShareTypeList(List idList) {
+ List shareTypeList = new ArrayList<>();
+ if (idList != null) {
+ for (String id : idList) {
+ for (ShareType shareType : ShareType.values()) {
+ if (id.equals(shareType.identifier)) {
+ shareTypeList.add(shareType);
+ }
+ }
+ }
+ }
+ return shareTypeList;
+ }
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/Step.java b/backbone/src/main/java/org/researchstack/backbone/step/Step.java
index af059ab52..1bc9728ab 100644
--- a/backbone/src/main/java/org/researchstack/backbone/step/Step.java
+++ b/backbone/src/main/java/org/researchstack/backbone/step/Step.java
@@ -1,6 +1,7 @@
package org.researchstack.backbone.step;
import org.researchstack.backbone.task.Task;
+import org.researchstack.backbone.utils.ObjectUtils;
import java.io.Serializable;
@@ -42,6 +43,11 @@ public class Step implements Serializable {
private boolean allowsBackNavigation;
private boolean useSurveyMode;
+ /* Default constructor needed for serilization/deserialization of object */
+ public Step() {
+ super();
+ }
+
/**
* Returns a new step initialized with the specified identifier.
*
@@ -72,6 +78,8 @@ public Step(String identifier, String title) {
*
* In some cases, it can be useful to link the step identifier to a unique identifier in a
* database; in other cases, it can make sense to make the identifier human readable.
+ *
+ * @return a short string that uniquely identifies the step
*/
public String getIdentifier() {
return identifier;
@@ -95,7 +103,7 @@ public boolean isOptional() {
/**
* Sets whether the step is skippable
*
- * @param optional
+ * @param optional a boolean indicating whether the step is skippable
* @see #isOptional()
*/
public void setOptional(boolean optional) {
@@ -131,7 +139,7 @@ public void setTitle(String title) {
* display a long question, it can work well to keep the title short and put the additional
* content in the text property.
*
- * @return
+ * @return the additional text to display
*/
public String getText() {
return text;
@@ -191,4 +199,40 @@ public Class getStepLayoutClass() {
public void setStepLayoutClass(Class stepLayoutClass) {
this.stepLayoutClass = stepLayoutClass;
}
+
+ /**
+ * @param newIdentifier to use instead of cloned step's identifier
+ * @return cloned step using Gson but with different identifier
+ */
+ public Step deepCopy(String newIdentifier) {
+ Step clonedStep = (Step)ObjectUtils.clone(this);
+ clonedStep.identifier = newIdentifier;
+ return clonedStep;
+ }
+
+ @Override
+ public int hashCode() {
+ if (identifier == null) {
+ return super.hashCode();
+ }
+ return identifier.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (!(obj instanceof Step)) {
+ return false;
+ }
+ if (obj == this) {
+ return true;
+ }
+
+ Step rhs = (Step) obj;
+
+ if (identifier == null || rhs.identifier == null) {
+ return false;
+ }
+
+ return identifier.equals(rhs.identifier);
+ }
}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/SubstepListStep.java b/backbone/src/main/java/org/researchstack/backbone/step/SubstepListStep.java
new file mode 100644
index 000000000..b5daca78f
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/SubstepListStep.java
@@ -0,0 +1,32 @@
+package org.researchstack.backbone.step;
+
+import org.researchstack.backbone.ui.step.layout.ViewPagerSubstepListStepLayout;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 1/16/17.
+ */
+
+public class SubstepListStep extends Step {
+ List stepList;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ protected SubstepListStep() {
+ super();
+ }
+
+ public SubstepListStep(String identifier, List stepList) {
+ super(identifier);
+ this.stepList = stepList;
+ }
+
+ public List getStepList() {
+ return stepList;
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return ViewPagerSubstepListStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/SubtaskStep.java b/backbone/src/main/java/org/researchstack/backbone/step/SubtaskStep.java
new file mode 100644
index 000000000..05d99ebea
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/SubtaskStep.java
@@ -0,0 +1,171 @@
+package org.researchstack.backbone.step;
+
+import android.util.Log;
+
+import org.researchstack.backbone.result.Result;
+import org.researchstack.backbone.result.StepResult;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.task.Task;
+import org.researchstack.backbone.ui.step.layout.ViewPagerSubstepListStepLayout;
+import org.researchstack.backbone.utils.ObjectUtils;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Created by TheMDP on 12/29/16.
+ */
+
+public class SubtaskStep extends Step {
+
+ private static final String LOG_TAG = SubtaskStep.class.getCanonicalName();
+
+ Task subtask;
+ public Task getSubtask() {
+ return subtask;
+ }
+
+ /* Default constructor needed for serilization/deserialization of object */
+ SubtaskStep() {
+ super();
+ }
+
+ public SubtaskStep(String identifier) {
+ super(identifier);
+ }
+
+ public SubtaskStep(String identifier, String title) {
+ super(identifier, title);
+ }
+
+ public SubtaskStep(String identifier, List steps) {
+ this(identifier);
+ subtask = new NavigableOrderedTask(identifier, steps);
+ }
+
+ public SubtaskStep(Task task) {
+ this(task.getIdentifier());
+ subtask = task;
+ }
+
+ /**
+ * @param identifier full identifier, i.e. "consent.step1"
+ * @return identifier with subtask identifier stripped out, i.e. "step1"
+ */
+ protected String substepIdentifier(String identifier) {
+ if (subtask == null) {
+ Log.e(LOG_TAG, "Subtask is null subtask step");
+ return null;
+ }
+
+ if (identifier == null || subtask.getIdentifier() == null) {
+ Log.e(LOG_TAG, "Identifier or subtask identifier is null in subtask step");
+ return null;
+ }
+
+ // Add a period to the end of the substep
+ String baseIdPrefix = subtask.getIdentifier() + ".";
+ int startIndex = identifier.indexOf(baseIdPrefix);
+
+ if (startIndex < 0) {
+ return null;
+ }
+
+ String substepId = identifier.substring(startIndex + baseIdPrefix.length());
+ return substepId;
+ }
+
+ private Step replacementStep(Step step) {
+ if (step == null) {
+ //Log.e(LOG_TAG, "Step is null in subtask step method");
+ return null;
+ }
+ String replacementIdentifier = subtask.getIdentifier() + "." + step.getIdentifier();
+ Step replacementStep = step.deepCopy(replacementIdentifier);
+ return replacementStep;
+ }
+
+ protected TaskResult filteredTaskResult(TaskResult inputResult) {
+ // create a mutated copy of the results that includes only the subtask results
+ TaskResult subtaskResult = (TaskResult) ObjectUtils.clone(inputResult);
+ Map stepResults = subtaskResult.getResults();
+ if (stepResults != null && !stepResults.keySet().isEmpty()) {
+ Map subtaskResults = filteredStepResults(stepResults);
+ subtaskResult.setResults(subtaskResults);
+ }
+ return subtaskResult;
+ }
+
+ private Map filteredStepResults(Map inputResults) {
+ Map subtaskResults = new LinkedHashMap<>();
+ String prefix = subtask.getIdentifier() + ".";
+ for (String identifier : inputResults.keySet()) {
+ if (identifier.startsWith(prefix)) {
+
+ Map newResultMap = new LinkedHashMap<>();
+ String newIdentifier = identifier.substring(prefix.length());
+ if (inputResults.get(identifier) != null) {
+ StepResult stepResult = (StepResult) inputResults.get(identifier).deepCopy(newIdentifier);
+
+ // Search results of the step for non-subtask identifiers as well
+ if (stepResult.getResults() != null) {
+ for (Object stepResultIdentifierObj : stepResult.getResults().keySet()) {
+ if (stepResultIdentifierObj instanceof String) {
+ String stepResultIdentifier = (String) stepResultIdentifierObj;
+ Object stepResultObject = stepResult.getResults().get(stepResultIdentifierObj);
+ if (stepResultObject instanceof Result) {
+ Result newResult = ((Result) stepResultObject).deepCopy(stepResultIdentifier);
+ newResultMap.put(stepResultIdentifier, newResult);
+ } else {
+ newResultMap.put(stepResultIdentifier, stepResultObject);
+ }
+ }
+ }
+ stepResult.setResults(newResultMap);
+ }
+
+ subtaskResults.put(newIdentifier, stepResult);
+ }
+ }
+ }
+ return subtaskResults;
+ }
+
+ public Step getStepWithIdentifier(String identifier) {
+ String substepIdentifier = substepIdentifier(identifier);
+ if (substepIdentifier == null) {
+ return null;
+ }
+ Step step = subtask.getStepWithIdentifier(substepIdentifier);
+ if (step == null) {
+ return null;
+ }
+ return replacementStep(step);
+ }
+
+ public Step getStepAfterStep(Step step, TaskResult result) {
+ if (step == null) {
+ return replacementStep(subtask.getStepAfterStep(null, result));
+ }
+ String substepIdentifier = substepIdentifier(step.getIdentifier());
+ if (substepIdentifier == null) {
+ return null;
+ }
+
+ Step substep = step.deepCopy(substepIdentifier);
+ TaskResult replacementTaskResult = filteredTaskResult(result);
+ Step nextStep = subtask.getStepAfterStep(substep, replacementTaskResult);
+
+ // If the task result was mutated, need to add any changes back into the result set
+ StepResult thisStepResult = replacementTaskResult.getStepResult(substepIdentifier);
+ if (thisStepResult != null && result != null) {
+ StepResult parentStepResult = result.getStepResult(step.getIdentifier());
+ parentStepResult.setResults(thisStepResult.getResults());
+ }
+
+ // And finally return the replacement step
+ return replacementStep(nextStep);
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/ActiveStep.java b/backbone/src/main/java/org/researchstack/backbone/step/active/ActiveStep.java
new file mode 100644
index 000000000..d4016c352
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/ActiveStep.java
@@ -0,0 +1,409 @@
+package org.researchstack.backbone.step.active;
+
+import org.researchstack.backbone.step.Step;
+import org.researchstack.backbone.step.active.recorder.RecorderConfig;
+import org.researchstack.backbone.ui.ActiveTaskActivity;
+import org.researchstack.backbone.ui.step.layout.ActiveStepLayout;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Created by TheMDP on 2/4/17.
+ *
+ * The `ORKActiveStep` class is the base class for steps in active tasks, which
+ * are steps that collect sensor data in a semi-controlled environment, as opposed
+ * to the more subjective data collected when users fill in surveys.
+ *
+ * In addition to the behaviors of `Step`, active steps have the concept of
+ * life cycle, which includes a defined start and finish.
+ *
+ * The ResearchStack library provides built-in behaviors that allow active steps
+ * to play voice prompts, speak a count down, and have a defined duration.
+ *
+ * To present an active step in your app, it's likely that you will subclass `ActiveStep` and
+ * `ActiveStepLayout` to present custom UI and custom
+ * prompts. For example subclasses, see `SpatialSpanMemoryStep` or `FitnessStep`.
+ *
+ * Active steps may also need `StepResult` subclasses to record their results
+ * if these don't come purely from recorders.
+ *
+ * If you develop a new active step subclass, consider contributing your
+ * code to the ResearchStack project so that it's available for others to use in
+ * their studies.
+ */
+
+public class ActiveStep extends Step {
+
+ /**
+ * The duration of the step in seconds.
+ *
+ * If the step duration is greater than zero, a built-in timer starts when the
+ * step starts. If `shouldStartTimerAutomatically` is set, the timer
+ * starts when the step's view appears. When the timer expires, a sound or
+ * vibration may be played. If `shouldContinueOnFinish` is set, the step
+ * automatically navigates forward when the timer expires.
+ *
+ * The default value of this property is `0`, which disables the built-in timer.
+ */
+ private int stepDuration = 0;
+
+ /**
+ * A Boolean value indicating whether to show a view with a default timer.
+ *
+ * The default timer UI is not used in any of the current predefined tasks,
+ * but it can be displayed in a simple active task that does not require custom
+ * UI and needs only a count down timer on screen during data collection.
+ *
+ * Note that this property is ignored if `stepDuration` is `0`.
+ *
+ * The default value of this property is true.
+ */
+ private boolean shouldShowDefaultTimer = true;
+
+ /**
+ * A Boolean value indicating whether to speak the last few seconds in the count down of the
+ * duration of a timed step.
+ *
+ * When the value of this property is `true`, `TextToSpeech` is used to synthesize the countdown.
+ * Note that this member variable is ignored if VoiceOver is enabled.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldSpeakCountDown = false;
+
+ /**
+ * A Boolean value indicating whether to speak the halfway point in the count down of the
+ * duration of a timed step.
+ *
+ * When the value of this property is `true`, `TextToSpeech` is used to synthesize the countdown.
+ * Note that this member variable is ignored if VoiceOver is enabled.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldSpeakRemainingTimeAtHalfway = false;
+
+ /**
+ * A Boolean value indicating whether to start the count down timer automatically when the step starts, or
+ * require the user to take some explicit action to start the step, such as tapping a button.
+ *
+ * Usually the explicit action needs to come from custom UI in an `ActiveStepLayout` subclass.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldStartTimerAutomatically = false;
+
+ /**
+ * A Boolean value indicating whether to play a default sound when the step starts.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldPlaySoundOnStart = false;
+
+ /**
+ * A Boolean value indicating whether to play a default sound when the step finishes.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldPlaySoundOnFinish = false;
+
+ /**
+ * A Boolean value indicating whether to vibrate when the step starts.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldVibrateOnStart = false;
+
+ /**
+ * A Boolean value indicating whether to vibrate when the step finishes.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldVibrateOnFinish = false;
+
+ /**
+ * A Boolean value indicating whether the Next button should double as a skip action before
+ * the step finishes.
+ *
+ * When the value of this property is `true`, the ResearchStack library hides the skip button and
+ * makes the Next button function as a skip button when the step has not yet finished.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldUseNextAsSkipButton = false;
+
+ /**
+ * A Boolean value indicating whether to transition automatically when the step finishes.
+ *
+ * When the value of this property is `true`, the active step layout automatically performs the
+ * continue action when the `[ActiveStepLayout finish]` method
+ * is called.
+ *
+ * The default value of this property is `false`.
+ */
+ private boolean shouldContinueOnFinish = false;
+
+ /**
+ * Localized text that represents an instructional voice prompt.
+ *
+ * Instructional speech begins when the step starts. If VoiceOver is active,
+ * the instruction is spoken by VoiceOver.
+ */
+ private String spokenInstruction;
+
+ /**
+ * Localized text that represents an instructional voice prompt for when the step finishes.
+ *
+ * Instructional speech begins when the step finishes. If VoiceOver is active,
+ * the instruction is spoken by VoiceOver.
+ */
+ private String finishedSpokenInstruction;
+
+ /**
+ * An array of recorder configurations that define the parameters for recorders to be
+ * run during a step to collect sensor or other data.
+ *
+ * If you want to collect data from sensors while the step is in progress,
+ * add one or more recorder configurations to the array. The active step view
+ * controller instantiates recorders and collates their results as children
+ * of the step result.
+ *
+ * The set of recorder configurations is scanned when populating the
+ * `requestedHealthKitTypesForReading` and `requestedPermissions` properties.
+ *
+ * See also: `ORKRecorderConfiguration` and `ORKRecorder`.
+ */
+ private List recorderConfigurationList;
+
+ /**
+ * An image to be displayed below the instructions for the step.
+ *
+ * It will be loaded from resources with the value of this variable
+ */
+ private String imageResName;
+
+ /**
+ * A map of <"time_in_seconds_to_speak", "what_to_speak">
+ */
+ private Map spokenInstructionMap;
+
+ /**
+ * A string representation of a raw file resource
+ */
+ private String soundRes;
+
+ /**
+ * The recording UUID is a unique identifier used by the RecorderService
+ */
+ private UUID recordingUuid;
+
+ /**
+ * This can be increased to allow the ending spoken instruction to
+ * not get cut off if it is too long
+ */
+ private int estimateTimeInMsToSpeakEndInstruction = 1000; // 1 second delay after finishing
+
+ /**
+ * The class of the activity that will run this step
+ */
+ private Class extends ActiveTaskActivity> activityClazz = ActiveTaskActivity.class;
+
+ /* Default constructor needed for serialization/deserialization of object */
+ ActiveStep() {
+ super();
+ }
+
+ public ActiveStep(String identifier) {
+ super(identifier);
+ setOptional(false);
+ recordingUuid = UUID.randomUUID();
+ }
+
+ public ActiveStep(String identifier, String title, String detailText) {
+ super(identifier, title);
+ setText(detailText);
+ setOptional(false);
+ recordingUuid = UUID.randomUUID();
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return ActiveStepLayout.class;
+ }
+
+ public void setStepDuration(int stepDuration) {
+ this.stepDuration = stepDuration;
+ }
+
+ public int getStepDuration() {
+ return stepDuration;
+ }
+
+ public void setShouldShowDefaultTimer(boolean shouldShowDefaultTimer) {
+ this.shouldShowDefaultTimer = shouldShowDefaultTimer;
+ }
+
+ public boolean getShouldShowDefaultTimer() {
+ return shouldShowDefaultTimer;
+ }
+
+ public void setShouldSpeakCountDown(boolean shouldSpeakCountDown) {
+ this.shouldSpeakCountDown = shouldSpeakCountDown;
+ }
+
+ public boolean getShouldSpeakCountDown() {
+ return shouldSpeakCountDown;
+ }
+
+ public void setShouldSpeakRemainingTimeAtHalfway(boolean shouldSpeakRemainingTimeAtHalfway) {
+ this.shouldSpeakRemainingTimeAtHalfway = shouldSpeakRemainingTimeAtHalfway;
+ }
+
+ public boolean getShouldSpeakRemainingTimeAtHalfway() {
+ return shouldSpeakRemainingTimeAtHalfway;
+ }
+
+ public void setShouldStartTimerAutomatically(boolean shouldStartTimerAutomatically) {
+ this.shouldStartTimerAutomatically = shouldStartTimerAutomatically;
+ }
+
+ public boolean getShouldStartTimerAutomatically() {
+ return shouldStartTimerAutomatically;
+ }
+
+ public boolean getShouldPlaySoundOnStart() {
+ return shouldPlaySoundOnStart;
+ }
+
+ public void setShouldPlaySoundOnStart(boolean shouldPlaySoundOnStart) {
+ this.shouldPlaySoundOnStart = shouldPlaySoundOnStart;
+ }
+
+ public boolean getShouldPlaySoundOnFinish() {
+ return shouldPlaySoundOnFinish;
+ }
+
+ public void setShouldPlaySoundOnFinish(boolean shouldPlaySoundOnFinish) {
+ this.shouldPlaySoundOnFinish = shouldPlaySoundOnFinish;
+ }
+
+ public boolean getShouldVibrateOnStart() {
+ return shouldVibrateOnStart;
+ }
+
+ public void setShouldVibrateOnStart(boolean shouldVibrateOnStart) {
+ this.shouldVibrateOnStart = shouldVibrateOnStart;
+ }
+
+ public boolean getShouldVibrateOnFinish() {
+ return shouldVibrateOnFinish;
+ }
+
+ public void setShouldVibrateOnFinish(boolean shouldVibrateOnFinish) {
+ this.shouldVibrateOnFinish = shouldVibrateOnFinish;
+ }
+
+ public boolean getShouldUseNextAsSkipButton() {
+ return shouldUseNextAsSkipButton;
+ }
+
+ public void setShouldUseNextAsSkipButton(boolean shouldUseNextAsSkipButton) {
+ this.shouldUseNextAsSkipButton = shouldUseNextAsSkipButton;
+ }
+
+ public boolean getShouldContinueOnFinish() {
+ return shouldContinueOnFinish;
+ }
+
+ public void setShouldContinueOnFinish(boolean shouldContinueOnFinish) {
+ this.shouldContinueOnFinish = shouldContinueOnFinish;
+ }
+
+ public String getSpokenInstruction() {
+ return spokenInstruction;
+ }
+
+ public void setSpokenInstruction(String spokenInstruction) {
+ this.spokenInstruction = spokenInstruction;
+ }
+
+ public String getFinishedSpokenInstruction() {
+ return finishedSpokenInstruction;
+ }
+
+ public void setFinishedSpokenInstruction(String finishedSpokenInstruction) {
+ this.finishedSpokenInstruction = finishedSpokenInstruction;
+ }
+
+ public boolean startsFinished() {
+ return stepDuration == 0;
+ }
+
+ public boolean hasCountDown() {
+ return (stepDuration > 0) && shouldShowDefaultTimer;
+ }
+
+ public boolean hasVoice() {
+ boolean hasSpokenInstruction = spokenInstruction != null && !spokenInstruction.isEmpty();
+ boolean hasFinishedSpokenInstruction = finishedSpokenInstruction != null && !finishedSpokenInstruction.isEmpty();
+ boolean hasValidSpeakingMap = spokenInstructionMap != null && !spokenInstructionMap.isEmpty();
+ return (hasSpokenInstruction || hasFinishedSpokenInstruction || hasValidSpeakingMap);
+ }
+
+ public List getRecorderConfigurationList() {
+ return recorderConfigurationList;
+ }
+
+ public void setRecorderConfigurationList(List recorderConfigurationList) {
+ this.recorderConfigurationList = recorderConfigurationList;
+ }
+
+ public String getImageResName() {
+ return imageResName;
+ }
+
+ public void setImageResName(String imageResName) {
+ this.imageResName = imageResName;
+ }
+
+ public Map getSpokenInstructionMap() {
+ return spokenInstructionMap;
+ }
+
+ public void setSpokenInstructionMap(Map spokenInstructions) {
+ spokenInstructionMap = spokenInstructions;
+ }
+
+ public int getEstimateTimeInMsToSpeakEndInstruction() {
+ return estimateTimeInMsToSpeakEndInstruction;
+ }
+
+ public void setEstimateTimeInMsToSpeakEndInstruction(int estimateTimeInMsToSpeakEndInstruction) {
+ this.estimateTimeInMsToSpeakEndInstruction = estimateTimeInMsToSpeakEndInstruction;
+ }
+
+ public UUID getRecordingUuid() {
+ return recordingUuid;
+ }
+
+ public void setRecordingUuid(UUID recordingUuid) {
+ this.recordingUuid = recordingUuid;
+ }
+
+ public Class extends ActiveTaskActivity> getActivityClazz() {
+ return activityClazz;
+ }
+
+ public void setActivityClazz(Class extends ActiveTaskActivity> activityClazz) {
+ this.activityClazz = activityClazz;
+ }
+
+ public String getSoundRes() {
+ return soundRes;
+ }
+
+ public void setSoundRes(String soundRes) {
+ this.soundRes = soundRes;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/ActiveTaskAndResultListener.java b/backbone/src/main/java/org/researchstack/backbone/step/active/ActiveTaskAndResultListener.java
new file mode 100644
index 000000000..587be2eb1
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/ActiveTaskAndResultListener.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2018 Sage Bionetworks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.researchstack.backbone.step.active;
+
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.Task;
+
+/**
+ * Created by TheMDP on 1/12/18.
+ */
+
+public interface ActiveTaskAndResultListener {
+ Task activeTaskActivityGetTask();
+ TaskResult activeTaskActivityResult();
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/AudioStep.java b/backbone/src/main/java/org/researchstack/backbone/step/active/AudioStep.java
new file mode 100644
index 000000000..3f6e8846d
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/AudioStep.java
@@ -0,0 +1,35 @@
+package org.researchstack.backbone.step.active;
+
+import org.researchstack.backbone.ui.step.layout.AudioStepLayout;
+
+/**
+ * Created by TheMDP on 2/27/17.
+ */
+
+public class AudioStep extends ActiveStep {
+
+ /* Default constructor needed for serialization/deserialization of object */
+ AudioStep() {
+ super();
+ }
+
+ public AudioStep(String identifier) {
+ super(identifier);
+ commonInit();
+ }
+
+ public AudioStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ commonInit();
+ }
+
+ protected void commonInit() {
+ setShouldShowDefaultTimer(false);
+ setShouldStartTimerAutomatically(true);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return AudioStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/CountdownStep.java b/backbone/src/main/java/org/researchstack/backbone/step/active/CountdownStep.java
new file mode 100644
index 000000000..ea3555f2c
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/CountdownStep.java
@@ -0,0 +1,39 @@
+package org.researchstack.backbone.step.active;
+
+import org.researchstack.backbone.ui.step.layout.CountdownStepLayout;
+
+/**
+ * Created by TheMDP on 2/4/17.
+ *
+ * The `CountdownStep` class represents a step that displays a label and a
+ * countdown for a time equal to its duration.
+ *
+ * To use the countdown step, set the `duration` property, incorporate it into a
+ * task, and present the task with ViewTaskActivity.
+ *
+ * The countdown step is used in most of ResearchStacks's predefined active tasks.
+ */
+
+public class CountdownStep extends ActiveStep {
+
+ public static final int DEFAULT_STEP_DURATION = 5;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ CountdownStep() {
+ super();
+ }
+
+ public CountdownStep(String identifier) {
+ super(identifier);
+ setStepDuration(DEFAULT_STEP_DURATION);
+ setShouldStartTimerAutomatically(true);
+ setShouldShowDefaultTimer(false);
+ setShouldContinueOnFinish(true);
+ setEstimateTimeInMsToSpeakEndInstruction(0); // do not wait to proceed
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return CountdownStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/FitnessStep.java b/backbone/src/main/java/org/researchstack/backbone/step/active/FitnessStep.java
new file mode 100644
index 000000000..22d9d356f
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/FitnessStep.java
@@ -0,0 +1,30 @@
+package org.researchstack.backbone.step.active;
+
+import org.researchstack.backbone.ui.step.layout.FitnessStepLayout;
+
+/**
+ * Created by TheMDP on 2/16/17.
+ */
+
+public class FitnessStep extends ActiveStep {
+
+ /* Default constructor needed for serilization/deserialization of object */
+ FitnessStep() {
+ super();
+ }
+
+ public FitnessStep(String identifier) {
+ super(identifier);
+ setShouldShowDefaultTimer(false);
+ }
+
+ public FitnessStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ setShouldShowDefaultTimer(false);
+ }
+
+ @Override
+ public Class getStepLayoutClass() {
+ return FitnessStepLayout.class;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/NavigationActiveStep.java b/backbone/src/main/java/org/researchstack/backbone/step/active/NavigationActiveStep.java
new file mode 100644
index 000000000..ea14554e8
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/NavigationActiveStep.java
@@ -0,0 +1,50 @@
+package org.researchstack.backbone.step.active;
+
+import org.researchstack.backbone.result.StepResult;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.task.NavigableOrderedTask;
+import org.researchstack.backbone.utils.StepHelper;
+import org.researchstack.backbone.utils.StepResultHelper;
+
+import java.util.List;
+
+/**
+ * Created by TheMDP on 2/5/17.
+ *
+ * Class enabled an ActiveStep to be navigable with a custom NavigationRule
+ */
+
+public class NavigationActiveStep extends ActiveStep implements NavigableOrderedTask.NavigationRule {
+
+ private List customRules;
+
+ /* Default constructor needed for serilization/deserialization of object */
+ NavigationActiveStep() {
+ super();
+ }
+
+ public NavigationActiveStep(String identifier) {
+ super(identifier);
+ }
+
+ public NavigationActiveStep(String identifier, String title, String detailText) {
+ super(identifier, title, detailText);
+ }
+
+ public void setCustomRules(List customRules) {
+ this.customRules = customRules;
+ }
+
+ @Override
+ public String nextStepIdentifier(TaskResult result, List additionalTaskResults) {
+ if (customRules != null) {
+ for (NavigableOrderedTask.ObjectEqualsNavigationRule rule : customRules) {
+ String nextIdentifier = rule.nextStepIdentifier(result, additionalTaskResults);
+ if (nextIdentifier != null) {
+ return nextIdentifier;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/backbone/src/main/java/org/researchstack/backbone/step/active/RecorderService.java b/backbone/src/main/java/org/researchstack/backbone/step/active/RecorderService.java
new file mode 100644
index 000000000..f98bf5cea
--- /dev/null
+++ b/backbone/src/main/java/org/researchstack/backbone/step/active/RecorderService.java
@@ -0,0 +1,712 @@
+/*
+ * Copyright 2018 Sage Bionetworks
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.researchstack.backbone.step.active;
+
+import android.Manifest;
+import android.annotation.TargetApi;
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.media.AudioManager;
+import android.media.MediaPlayer;
+import android.media.ToneGenerator;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Vibrator;
+import android.speech.tts.TextToSpeech;
+import androidx.annotation.Nullable;
+import androidx.annotation.RawRes;
+import androidx.annotation.RequiresPermission;
+import androidx.core.app.NotificationCompat;
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+import android.util.Log;
+
+import com.google.gson.Gson;
+
+import org.researchstack.backbone.R;
+import org.researchstack.backbone.result.FileResult;
+import org.researchstack.backbone.result.Result;
+import org.researchstack.backbone.result.TaskResult;
+import org.researchstack.backbone.step.active.recorder.Recorder;
+import org.researchstack.backbone.step.active.recorder.RecorderConfig;
+import org.researchstack.backbone.step.active.recorder.RecorderListener;
+import org.researchstack.backbone.task.Task;
+import org.researchstack.backbone.ui.ViewTaskActivity;
+import org.researchstack.backbone.utils.LogExt;
+import org.researchstack.backbone.utils.ResUtils;
+
+import java.io.File;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import static org.researchstack.backbone.ui.ViewTaskActivity.EXTRA_STEP;
+import static org.researchstack.backbone.ui.ViewTaskActivity.EXTRA_TASK;
+import static org.researchstack.backbone.ui.ViewTaskActivity.EXTRA_TASK_RESULT;
+
+/**
+ * Created by TheMDP on 1/10/18.
+ */
+
+public class RecorderService extends Service implements RecorderListener, TextToSpeech.OnInitListener {
+
+ public static final String RECORDER_PREFS_KEY = "RecorderServicePrefs";
+ public static final String RECORDER_PREFS_RESULTS_KEY = "Results";
+ public static final String RECORDER_PREFS_START_TIME_KEY = "StartTime";
+
+ public static final int DEFAULT_VIBRATION_AND_SOUND_DURATION = 500; // in milliseconds
+ private static final String NOTIFICATION_CHANNEL_ID = "RecorderService_NotificationChannel";
+ private static final String NOTIFICATION_CHANNEL_TITLE = "Study in-activity progress tracker";
+ private static final String NOTIFICATION_CHANNEL_DESC = "Records and shows your progress during an "
+ + "activity.";
+
+ public static final String INTENT_ACTION_RECORDER_RESUME = "INTENT_ACTION_RECORDER_RESUME";
+
+ private static final String INTENT_KEY_OUTPUT_DIRECTORY = "RecorderOutputDirectory";
+
+ public static String ACTION_BROADCAST_RECORDER_COMPLETE = "RecorderService_RecordingComplete";
+ public static String ACTION_BROADCAST_RECORDER_METRONOME = "RecorderService_MetronomeBroadcast";
+ public static String ACTION_BROADCAST_RECORDER_SPOKEN_TEXT = "RecorderService_SpokenTextBroadcast";
+
+ public static String BROADCAST_RECORDER_METRONOME_CTR = "RecorderService_MetronomeCtr";
+ public static String BROADCAST_RECORDER_SPOKEN_TEXT = "RecorderService_SpokenText";
+
+ // keys associated with spokenInstructions json recorder configs
+ public static final String TEXT_TO_SPEECH_END_KEY = "end";
+ public static final String TEXT_TO_SPEECH_COUNTDOWN_KEY = "countdown";
+ public static final String TEXT_TO_SPEECH_METRONOME_KEY = "metronome";
+
+ protected long startTime;
+ protected Handler mainHandler;
+
+ protected List recorderList;
+ protected List resultList;
+ protected ActiveStep activeStep;
+ protected Task task;
+ protected TaskResult taskResult;
+
+ protected Notification foregroundNotification;
+
+ protected MediaPlayer mediaPlayer;
+ protected TextToSpeech tts;
+ protected String textToSpeakOnInit;
+ protected boolean isWaitingToComplete;
+ protected boolean isServiceRunning;
+ protected boolean shouldCancelRecordersOnDestroy;
+
+ /**
+ * @param appContext
+ * @return the saved result list, if this active step has already completed recording
+ */
+ public static ResultHolder consumeSavedResultList(Context appContext, ActiveStep activeStep) {
+ SharedPreferences prefs = appContext.getSharedPreferences(RECORDER_PREFS_KEY, MODE_PRIVATE);
+ String key = activeStep.getRecordingUuid().toString() + RECORDER_PREFS_RESULTS_KEY;
+ if (!prefs.contains(key)) {
+ return null;
+ }
+ String resultHolderJson = prefs.getString(key, null);
+ if (resultHolderJson == null) {
+ return null;
+ }
+ ResultHolder resultHolder = new Gson().fromJson(resultHolderJson, ResultHolder.class);
+ if (resultHolder == null) {
+ return null;
+ }
+ // This was a valid recorder result that was just read, so consume it
+ prefs.edit().remove(key).apply();
+ return resultHolder;
+ }
+
+ /**
+ * @param appContext
+ * @param activeStep to use to search for a previous start time
+ * @return null if the active step recording has not been started, null otherwise
+ */
+ public static Long getStartTime(Context appContext, ActiveStep activeStep) {
+ SharedPreferences prefs = appContext.getSharedPreferences(RECORDER_PREFS_KEY, MODE_PRIVATE);
+ String key = activeStep.getRecordingUuid().toString() + RECORDER_PREFS_START_TIME_KEY;
+ if (!prefs.contains(key)) {
+ return null;
+ }
+ long startTime = prefs.getLong(key, -1);
+ if (startTime < 0) {
+ return null;
+ }
+ return startTime;
+ }
+
+ protected static void setStartTime(Context appContext, ActiveStep activeStep, long startTime) {
+ LogExt.d(RecorderService.class, "setStartTime() " + startTime);
+ SharedPreferences prefs = appContext.getSharedPreferences(RECORDER_PREFS_KEY, MODE_PRIVATE);
+ String key = activeStep.getRecordingUuid().toString() + RECORDER_PREFS_START_TIME_KEY;
+ prefs.edit().putLong(key, startTime).apply();
+ }
+
+ protected static void removeStartTime(Context appContext, ActiveStep activeStep) {
+ LogExt.d(RecorderService.class, "removeStartTime()");
+ SharedPreferences prefs = appContext.getSharedPreferences(RECORDER_PREFS_KEY, MODE_PRIVATE);
+ String key = activeStep.getRecordingUuid().toString() + RECORDER_PREFS_START_TIME_KEY;
+ prefs.edit().remove(key).apply();
+ }
+
+ /**
+ * @param appContext
+ * @return the saved result list, if this active step has already completed recording
+ */
+ protected static void setSavedResultList(Context appContext,
+ ActiveStep activeStep,
+ ResultHolder resultHolder) {
+
+ SharedPreferences prefs = appContext.getSharedPreferences(RECORDER_PREFS_KEY, MODE_PRIVATE);
+ String key = activeStep.getRecordingUuid().toString() + RECORDER_PREFS_RESULTS_KEY;
+ String resultListHolderJson = new Gson().toJson(resultHolder);
+ // commit needed since this may be read immediately
+ prefs.edit().putString(key, resultListHolderJson).commit();
+ // Once we have stored the result, we should remove the startTime pref
+ removeStartTime(appContext, activeStep);
+ }
+
+ /**
+ * @param appContext needed to create the intent
+ * @param activeStep currently being run, must contain a valid stepDuration
+ * @param outputDirectory for the recorders
+ * @param task the task for this activeStep, used in the Notification PendingIntent
+ * @param taskResult the current TaskResult, used in the Notification PendingIntent
+ * @return the intent to launch via "appContext.startService()"
+ */
+ public static void startService(Context appContext,
+ File outputDirectory,
+ ActiveStep activeStep,
+ Task task,
+ TaskResult taskResult) {
+
+ Intent intent = new Intent(appContext, RecorderService.class);
+ Bundle extras = new Bundle();
+
+ extras.putSerializable(EXTRA_STEP, activeStep);
+ extras.putSerializable(ViewTaskActivity.EXTRA_TASK, task);
+ extras.putSerializable(ViewTaskActivity.EXTRA_TASK_RESULT, taskResult);
+ extras.putSerializable(INTENT_KEY_OUTPUT_DIRECTORY, outputDirectory);
+
+ intent.putExtras(extras);
+ appContext.startService(intent);
+ }
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ LogExt.d(RecorderService.class, "onCreate");
+
+ // no-op, wait for onStartCommand
+ }
+
+ @Override
+ public int onStartCommand(Intent intent, int flags, int startId) {
+ LogExt.d(RecorderService.class, "onStartCommand");
+
+ if (isServiceRunning) {
+ LogExt.e(RecorderService.class, "RecorderService already running, " +
+ "ignoring duplicate start command");
+ return START_NOT_STICKY;
+ }
+
+ isServiceRunning = true;
+ shouldCancelRecordersOnDestroy = true;
+ mainHandler = new Handler();
+ resultList = new ArrayList<>();
+ recorderList = new ArrayList<>();
+ startTime = System.currentTimeMillis();
+ isWaitingToComplete = false;
+
+ // Starting with API 26, notifications must be contained in a channel
+ if (Build.VERSION.SDK_INT >= 26) {
+ NotificationManager notificationManager =
+ (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
+ if (notificationManager != null) {
+ NotificationChannel channel = new NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ NOTIFICATION_CHANNEL_TITLE,
+ NotificationManager.IMPORTANCE_DEFAULT);
+ channel.setDescription(NOTIFICATION_CHANNEL_DESC);
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+
+ File outputDir = null;
+ if (intent != null && intent.getExtras() != null) {
+ Bundle bundle = intent.getExtras();
+ if (bundle.containsKey(EXTRA_STEP)) {
+ activeStep = (ActiveStep)bundle.getSerializable(EXTRA_STEP);
+ }
+ if (bundle.containsKey(EXTRA_TASK)) {
+ task = (Task)bundle.getSerializable(EXTRA_TASK);
+ }
+ if (bundle.containsKey(EXTRA_TASK_RESULT)) {
+ taskResult = (TaskResult)bundle.getSerializable(EXTRA_TASK_RESULT);
+ if (taskResult == null && task != null) { // it may be that there is no results yet
+ taskResult = new TaskResult(task.getIdentifier());
+ }
+ }
+ if (bundle.containsKey(INTENT_KEY_OUTPUT_DIRECTORY)) {
+ outputDir = (File)bundle.getSerializable(INTENT_KEY_OUTPUT_DIRECTORY);
+ }
+ }
+
+ if (activeStep != null && activeStep.getStepDuration() > 0 &&
+ task != null && taskResult != null) {
+
+ LogExt.d(RecorderService.class, "Valid active step found, starting service");
+
+ if (activeStep.hasVoice()) {
+ tts = new TextToSpeech(this, this);
+ }
+
+ if (activeStep.getSoundRes() != null) {
+ @RawRes int soundRes = ResUtils
+ .getRawResourceId(this, activeStep.getSoundRes());
+ if (soundRes == 0) {
+ LogExt.e(RecorderService.class,
+ "Error finding ActiveStep's sound " + activeStep.getSoundRes());
+ } else {
+ mediaPlayer = MediaPlayer.create(this, soundRes);
+ mediaPlayer.start();
+ }
+ }
+
+ setStartTime(getApplicationContext(), activeStep, startTime);
+
+ String notificationTitle = getString(R.string.rsb_recorder_notification_title);
+ // This will ensure that this service is never destroyed
+ showForegroundNotification(notificationTitle);
+
+ // Start the recording process
+ if (activeStep.getRecorderConfigurationList() != null) {
+ for (RecorderConfig recorderConfig : activeStep.getRecorderConfigurationList()) {
+ Recorder recorder = recorderConfig.recorderForStep(activeStep, outputDir);
+ // recorder can be null if it requires custom setup,
+ // but that will require a custom RecorderService to handle
+ if (recorder != null) {
+ recorder.setRecorderListener(this);
+ recorderList.add(recorder);
+ recorder.start(getApplicationContext());
+ LogExt.d(RecorderService.class, "recorder started " + recorder.getIdentifier());
+ }
+ }
+ }
+
+ // Start the delayed operations that will happen during the life-cycle of the service
+ startDelayedOperations();
+
+ } else {
+ String errorMessage = "ActiveStep is null or does not have a valid step duration";
+ LogExt.e(RecorderService.class, errorMessage);
+ sendRecorderErrorBroadcast(errorMessage);
+ stopSelf();
+ }
+
+ return START_NOT_STICKY;
+ }
+
+ private void startDelayedOperations() {
+ // Now allow the recorder to record for as long as the active step requires
+ mainHandler.postDelayed(this::onRecorderDurationFinished,
+ activeStep.getStepDuration() * 1000L);
+
+ startSpeechToTextMap();
+ }
+
+ protected void startSpeechToTextMap() {
+
+ Map speechToTextMap = activeStep.getSpokenInstructionMap();
+ if (speechToTextMap != null) {
+ for (String speechKey : speechToTextMap.keySet()) {
+
+ // Check for special case "end" key that speaks after the step duration
+ if (TEXT_TO_SPEECH_END_KEY.equals(speechKey)) {
+ final String endSpeechText = speechToTextMap.get(speechKey);
+ mainHandler.postDelayed(() -> speakTextAndUpdateNotification(endSpeechText),
+ activeStep.getStepDuration() * 1000L);
+
+ // Check for special case "countdown" key that speaks a verbal seconds countdown
+ } else if (TEXT_TO_SPEECH_COUNTDOWN_KEY.equals(speechKey)) {
+ try {
+ int countdownTime = Integer.parseInt(speechToTextMap.get(speechKey));
+ for (int i = countdownTime; i > 0; i--) {
+ final String countDownStr = String.valueOf(i);
+ mainHandler.postDelayed(() -> speakText(countDownStr),
+ (activeStep.getStepDuration() - i) * 1000L);
+ }
+ } catch (NumberFormatException e) {
+ LogExt.e(RecorderService.class, e.getLocalizedMessage());
+ }
+ // All other cases will speak the text at the seconds time of the speechKey
+ } else if (TEXT_TO_SPEECH_METRONOME_KEY.equals(speechKey)) {
+ try {
+ double metronomeIntervalInSec =
+ Double.parseDouble(speechToTextMap.get(speechKey));
+ long metronomeIntervalInMs = (long)(metronomeIntervalInSec * 1000L);
+ long stopTimeInMs = activeStep.getStepDuration() * 1000L;
+ long metronomeTimeInMs = metronomeIntervalInMs;
+ final ToneGenerator tockSound =
+ new ToneGenerator(AudioManager.STREAM_MUSIC, 60);
+ int metronomeCtr = 0;
+ while (metronomeTimeInMs < stopTimeInMs) {
+ final int metronomeCounter = metronomeCtr;
+ mainHandler.postDelayed(() -> {
+ tockSound.startTone(ToneGenerator.TONE_CDMA_PIP, 100);
+ sendMetronomeBroadcast(metronomeCounter);
+ }, metronomeTimeInMs);
+ metronomeTimeInMs += metronomeIntervalInMs;
+ metronomeCtr++;
+ }
+ } catch (NumberFormatException e) {
+ LogExt.e(RecorderService.class, e.getLocalizedMessage());
+ }
+ // All other cases will speak the text at the seconds time of the speechKey
+ } else {
+ try {
+ double triggerTime = Double.parseDouble(speechKey);
+ final String speechText = speechToTextMap.get(speechKey);
+ mainHandler.postDelayed(() -> speakTextAndUpdateNotification(speechText),
+ (long)(triggerTime * 1000L));
+ } catch (NumberFormatException e) {
+ LogExt.e(RecorderService.class, e.getLocalizedMessage());
+ }
+ }
+ }
+ }
+ }
+
+ protected void speakTextAndUpdateNotification(String message) {
+ speakText(message);
+ showForegroundNotification(message);
+ }
+
+ /**
+ * This would never be called under normal operation while the recorders are running
+ * It will only be called when the user chooses to end the task and discard the results
+ * or in special very rare cases like user shut their phone off, serious memory issues, etc
+ * It should be treated like a recorder canceled scenario
+ */
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ LogExt.d(RecorderService.class, "onDestroyed service is stopping");
+
+ shutDownTts();
+ shutDownMediaPlayer();
+ mainHandler.removeCallbacksAndMessages(null);
+ if (isServiceRunning && shouldCancelRecordersOnDestroy) {
+ LogExt.d(RecorderService.class, "cancelling all recorders");
+ removeStartTime(getApplicationContext(), activeStep);
+ for (Recorder recorder : recorderList) {
+ recorder.cancel();
+ }
+ }
+ isServiceRunning = false;
+ }
+
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null; // no need, pass everything through LocalBroadcastManager and Intents
+ }
+
+ private void showForegroundNotification(String notificationMessage) {
+
+ // Starting with API 26, notifications must be contained in a channel
+ if (Build.VERSION.SDK_INT >= 26) {
+ NotificationManager notificationManager =
+ (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+ if (notificationManager != null) {
+ NotificationChannel channel = new NotificationChannel(
+ NOTIFICATION_CHANNEL_ID,
+ NOTIFICATION_CHANNEL_TITLE,
+ NotificationManager.IMPORTANCE_DEFAULT);
+ channel.setDescription(NOTIFICATION_CHANNEL_DESC);
+ notificationManager.createNotificationChannel(channel);
+ }
+ }
+
+ LogExt.d(RecorderService.class, "showForegroundNotification(" + notificationMessage + ")");
+ Intent notificationIntent = new Intent(this, activeStep.getActivityClazz());
+
+ // These will guarantee the activity is re-created at the same step as we were running
+ notificationIntent.putExtra(ViewTaskActivity.EXTRA_TASK, task);
+ notificationIntent.putExtra(ViewTaskActivity.EXTRA_TASK_RESULT, taskResult);
+ notificationIntent.putExtra(ViewTaskActivity.EXTRA_STEP, activeStep);
+
+ notificationIntent.setAction(INTENT_ACTION_RECORDER_RESUME);
+ notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
+ PendingIntent pendingIntent = PendingIntent.getActivity(
+ this, 0, notificationIntent, 0);
+
+ String msg = getString(R.string.rsb_recording);
+
+ NotificationCompat.Builder notificationBuilder =
+ new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
+ .setContentTitle(task.getIdentifier() + " " + msg)
+ .setContentText(notificationMessage)
+ .setContentIntent(pendingIntent);
+
+ // vector drawable crash
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ notificationBuilder.setSmallIcon(R.drawable.rsb_ic_recorder_notification);
+ }
+ foregroundNotification = notificationBuilder.build();
+ startForeground(1, foregroundNotification);
+ }
+
+ private void sendRecorderErrorBroadcast(String errorMessage) {
+ LogExt.d(RecorderService.class, "sendRecorderErrorBroadcast()");
+ Intent broadcastIntent = new Intent(ACTION_BROADCAST_RECORDER_COMPLETE);
+ ResultHolder resultHolder = new ResultHolder();
+ resultHolder.setErrorMessage(errorMessage);
+ resultHolder.setStartTime(startTime);
+ setSavedResultList(getApplicationContext(), activeStep, resultHolder);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(broadcastIntent);
+ }
+
+ private void sendRecorderCompleteBroadcast() {
+ LogExt.d(RecorderService.class, "sendRecorderCompleteBroadcast()");
+ Intent broadcastIntent = new Intent(ACTION_BROADCAST_RECORDER_COMPLETE);
+ ResultHolder resultHolder = new ResultHolder();
+ resultHolder.setResultList(resultList);
+ resultHolder.setStartTime(startTime);
+ setSavedResultList(getApplicationContext(), activeStep, resultHolder);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(broadcastIntent);
+ }
+
+ protected void sendMetronomeBroadcast(int ctr) {
+ Intent broadcastIntent = new Intent(ACTION_BROADCAST_RECORDER_METRONOME);
+ broadcastIntent.putExtra(BROADCAST_RECORDER_METRONOME_CTR, ctr);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(broadcastIntent);
+ }
+
+ protected void sendSpokenTextBroadcast(String spokenText) {
+ Intent broadcastIntent = new Intent(ACTION_BROADCAST_RECORDER_SPOKEN_TEXT);
+ broadcastIntent.putExtra(BROADCAST_RECORDER_SPOKEN_TEXT, spokenText);
+ LocalBroadcastManager.getInstance(this).sendBroadcast(broadcastIntent);
+ }
+
+ @RequiresPermission(value = Manifest.permission.VIBRATE, conditional = true)
+ private void onRecorderDurationFinished() {
+ LogExt.d(RecorderService.class, "onRecorderDurationFinished()");
+ isWaitingToComplete = true;
+
+ if (activeStep.getShouldVibrateOnFinish()) {
+ vibrate();
+ }
+
+ if (activeStep.getShouldPlaySoundOnFinish()) {
+ playSound();
+ }
+
+ if (activeStep.getFinishedSpokenInstruction() != null) {
+ speakText(activeStep.getFinishedSpokenInstruction());
+ }
+
+ // copy list to avoid any concurrent modifications with calling stop on each recorder
+ List copiedList = new ArrayList<>(recorderList);
+ for (Recorder recorder : copiedList) {
+ recorder.stop(); // this will trigger a call to onComplete below
+ LogExt.d(RecorderService.class, "recorder stopped " + recorder.getIdentifier());
+ }
+ // The continueOnFinishDelay allows the TTS to flush,
+ // and for the recorders to finish up before leaving the screen
+ mainHandler.postDelayed(() -> {
+ isWaitingToComplete = false;
+ if (!recorderList.isEmpty()) {
+ // continue to wait for recorders to finish before sending the complete broadcast
+ } else {
+ sendCompleteBroadcastAndFinish();
+ }
+ }, activeStep.getEstimateTimeInMsToSpeakEndInstruction());
+ }
+
+ /**
+ * RecorderListener callback
+ * @param recorder The generating recorder object.
+ * @param result The generated result.
+ */
+ @Override
+ public void onComplete(Recorder recorder, Result result) {
+ if (!(result instanceof FileResult)) {
+ // Due to the RecorderService having to store results in a SharedPreferences file
+ // We do not allow any Results other than FileResults
+ throw new IllegalStateException("RecorderService only works " +
+ "with Recorders that return FileResults");
+ }
+ FileResult fileResult = (FileResult)result;
+ LogExt.d(RecorderService.class, "recorder onComplete() " + result.getIdentifier());
+ recorderList.remove(recorder);
+ resultList.add(fileResult);
+ if (!isWaitingToComplete && recorderList.isEmpty()) {
+ sendCompleteBroadcastAndFinish();
+ }
+ }
+
+ protected void sendCompleteBroadcastAndFinish() {
+ shouldCancelRecordersOnDestroy = false;
+ sendRecorderCompleteBroadcast();
+ stopSelf();
+ }
+
+ /**
+ * RecorderListener callback
+ * @param recorder The generating recorder object.
+ * @param error The error that occurred.
+ */
+ @Override
+ public void onFail(Recorder recorder, Throwable error) {
+ shutDownTts();
+ shutDownMediaPlayer();
+ sendRecorderErrorBroadcast(error.getLocalizedMessage());
+ stopSelf();
+ }
+
+ protected void shutDownTts() {
+ if (tts != null) {
+ if (tts.isSpeaking()) {
+ tts.stop();
+ }
+ tts.shutdown();
+ tts = null;
+ }
+ }
+
+ protected void shutDownMediaPlayer() {
+ if (mediaPlayer != null) {
+ mediaPlayer.release();
+ mediaPlayer = null;
+ }
+ }
+
+ @Nullable
+ @Override
+ public Context getBroadcastContext() {
+ return this;
+ }
+
+ // TextToSpeech initialization
+ @Override
+ public void onInit(int i) {
+ if (i == TextToSpeech.SUCCESS) {
+ int languageAvailable = tts.isLanguageAvailable(Locale.getDefault());
+ // >= 0 means LANG_AVAILABLE, LANG_COUNTRY_AVAILABLE, or LANG_COUNTRY_VAR_AVAILABLE
+ if (languageAvailable >= 0) {
+ tts.setLanguage(Locale.getDefault());
+ if (textToSpeakOnInit != null) {
+ speakText(textToSpeakOnInit);
+ }
+ } else {
+ tts = null;
+ }
+ } else {
+ Log.e(getClass().getCanonicalName(), "Failed to initialize TTS with error code " + i);
+ tts = null;
+ }
+ }
+
+ protected void speakText(String text) {
+ // Setting this will guarantee the text gets spoken in the case that tts isn't set up yet
+ textToSpeakOnInit = text;
+ if (tts == null) {
+ return;
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ ttsGreater21(text);
+ } else {
+ ttsUnder20(text);
+ }
+ sendSpokenTextBroadcast(text);
+ }
+
+ @SuppressWarnings("deprecation")
+ private void ttsUnder20(String text) {
+ HashMap