diff --git a/src/cli/java/org/commcare/util/cli/ApplicationHost.java b/src/cli/java/org/commcare/util/cli/ApplicationHost.java index 5d9e9c1132..315cd837b3 100644 --- a/src/cli/java/org/commcare/util/cli/ApplicationHost.java +++ b/src/cli/java/org/commcare/util/cli/ApplicationHost.java @@ -7,6 +7,7 @@ import org.commcare.core.parse.ParseUtils; import org.commcare.core.sandbox.SandboxUtils; import org.commcare.data.xml.DataModelPullParser; +import org.commcare.modern.session.SessionWrapper; import org.commcare.session.SessionFrame; import org.commcare.suite.model.FormIdDatum; import org.commcare.suite.model.SessionDatum; @@ -14,7 +15,11 @@ import org.commcare.util.CommCarePlatform; import org.commcare.util.engine.CommCareConfigEngine; import org.commcare.util.mocks.CLISessionWrapper; +import org.commcare.util.mocks.CoreNetworkContext; +import org.commcare.util.mocks.JavaPlatformFormSubmitTool; +import org.commcare.util.mocks.JavaPlatformSyncTool; import org.commcare.util.mocks.MockUserDataSandbox; +import org.commcare.util.mocks.SyncStateMachine; import org.commcare.util.screen.CommCareSessionException; import org.commcare.util.screen.EntityScreen; import org.commcare.util.screen.MenuScreen; @@ -50,6 +55,7 @@ import java.net.HttpURLConnection; import java.net.PasswordAuthentication; import java.net.URL; +import java.util.ArrayList; /** * CLI host for running a commcare application which has been configured and instatiated @@ -75,6 +81,9 @@ public class ApplicationHost { private String mRestoreFile; private boolean mRestoreStrategySet = false; + private ArrayList formQueue = new ArrayList<>(); + private boolean submissionsEnabled = false; + public ApplicationHost(CommCareConfigEngine engine, PrototypeFactory prototypeFactory) { this.mEngine = engine; this.mPlatform = engine.getPlatform(); @@ -201,6 +210,11 @@ private boolean loopSession() throws IOException { continue; } + if(input.equals(":submit")) { + attemptFormSubmissions(); + continue; + } + if (input.startsWith(":lang")) { String[] langArgs = input.split(" "); if (langArgs.length != 2) { @@ -257,6 +271,9 @@ private boolean loopSession() throws IOException { if (!processResultInstance(player.getResultStream())) { return true; } + if(submissionsEnabled) { + formQueue.add(player.getResultBytes()); + } finishSession(); return true; } else if (player.getExecutionResult() == XFormPlayer.FormResult.Cancelled) { @@ -272,6 +289,33 @@ private boolean loopSession() throws IOException { return true; } + private void attemptFormSubmissions() { + if(!submissionsEnabled) { + System.out.println("Form submissions are not enabled! Please restart the cli with the 's' flag"); + return; + } + + if(this.formQueue.size() == 0) { + System.out.println("No pending forms"); + return; + } + JavaPlatformFormSubmitTool submitter = + new JavaPlatformFormSubmitTool(this.mSandbox, new CoreNetworkContext(mLocalUserCredentials[0],mLocalUserCredentials[1])); + + System.out.println(String.format("Submitting %d forms to %s", this.formQueue.size(), submitter.getSubmitUrl())); + + ArrayList copyQueue = new ArrayList<>(formQueue); + for(int i = 0 ; i < copyQueue.size() ; ++i) { + byte[] form = copyQueue.get(i); + System.out.println(String.format("Submitting Form [%d/%d].....", i+1, this.formQueue.size())); + if(submitter.submitFormToServer(form)) { + formQueue.remove(i); + } else { + System.out.println("Ending form submission due to failure. Retry to continue"); + } + } + } + private void printStack(CLISessionWrapper mSession) { SessionFrame frame = mSession.getFrame(); System.out.println("Live Frame"); @@ -411,76 +455,14 @@ private void initUser() { System.out.println("Setting logged in user to: " + u.getUsername()); } - public static void restoreUserToSandbox(UserSandbox sandbox, CLISessionWrapper session, + public static void restoreUserToSandbox(UserSandbox sandbox, SessionWrapper session, String username, final String password) { - String urlStateParams = ""; - - boolean failed = true; - - boolean incremental = false; - - if (sandbox.getLoggedInUser() != null) { - String syncToken = sandbox.getSyncToken(); - String caseStateHash = CaseDBUtils.computeCaseDbHash(sandbox.getCaseStorage()); - - urlStateParams = String.format("&since=%s&state=ccsh:%s", syncToken, caseStateHash); - incremental = true; - - System.out.println(String.format( - "\nIncremental sync requested. \nSync Token: %s\nState Hash: %s", - syncToken, caseStateHash)); - } - - //fetch the restore data and set credentials - String otaFreshRestoreUrl = PropertyManager.instance().getSingularProperty("ota-restore-url") + - "?version=2.0"; - - String otaSyncUrl = otaFreshRestoreUrl + urlStateParams; - - String domain = PropertyManager.instance().getSingularProperty("cc_user_domain"); - final String qualifiedUsername = username + "@" + domain; - - Authenticator.setDefault(new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(qualifiedUsername, password.toCharArray()); - } - }); - - //Go get our sandbox! - try { - System.out.println("GET: " + otaSyncUrl); - URL url = new URL(otaSyncUrl); - HttpURLConnection conn = (HttpURLConnection)url.openConnection(); - if (conn.getResponseCode() == 412) { - System.out.println("Server Response 412 - The user sandbox is not consistent with " + - "the server's data. \n\nThis is expected if you have changed cases locally, " + - "since data is not sent to the server for updates. \n\nServer response cannot be restored," + - " you will need to restart the user's session to get new data."); - } else if (conn.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) { - System.out.println("\nInvalid username or password!"); - } else if (conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { + JavaPlatformSyncTool tool = new JavaPlatformSyncTool(username, password, sandbox, session); - System.out.println("Restoring user " + username + " to domain " + domain); - ParseUtils.parseIntoSandbox(new BufferedInputStream(conn.getInputStream()), sandbox); + if(attemptSync(tool)) { - System.out.println("User data processed, new state token: " + sandbox.getSyncToken()); - failed = false; - } else { - System.out.println("Unclear/Unexpected server response code: " + conn.getResponseCode()); - } - } catch (InvalidStructureException | IOException - | XmlPullParserException | UnfullfilledRequirementsException e) { - e.printStackTrace(); - } - - if (failed) { - if (!incremental) { - System.exit(-1); - } - } else { //Initialize our User for (IStorageIterator iterator = sandbox.getUserStorage().iterate(); iterator.hasMore(); ) { User u = iterator.nextRecord(); @@ -488,11 +470,67 @@ protected PasswordAuthentication getPasswordAuthentication() { sandbox.setLoggedInUser(u); } } + + if (session != null) { + // old session data is now no longer valid + session.clearVolitiles(); + } } + } - if (session != null) { - // old session data is now no longer valid - session.clearVolitiles(); + private static boolean attemptSync(JavaPlatformSyncTool tool) { + try { + tool.initialize(); + + while (tool.getCurrentState() == SyncStateMachine.State.Ready_For_Request) { + + tool.performRequest(); + + switch (tool.getCurrentState()) { + case Waiting_For_Progress: + tool.processWaitSignal(); + break; + case Recovery_Requested: + tool.transitionToRecoveryStrategy(); + break; + case Recoverable_Error: + tool.resetFromError(); + break; + } + } + + tool.processPayload(); + return true; + } catch(SyncStateMachine.SyncErrorException e) { + String errorMessage = String.format("Sync Failed While [%s]\nError: ", + e.getStateBeforeError()); + + switch(e.getSyncError()){ + case Invalid_Credentials: + errorMessage += "Invalid user credentials"; + break; + case Unexpected_Server_Response_Code: + errorMessage += "Unexpected server response code: " + tool.getServerResponseCode(); + break; + case Network_Error: + errorMessage += "Network problems, please try to sync again"; + break; + case Invalid_Payload: + errorMessage += "Invalid Server Response"; + break; + case Unhandled_Situation: + errorMessage += "Unhandled Behavior"; + break; + } + if(e.getMessage() != null) { + errorMessage +="\nDetails: " + e.getMessage(); + } + System.out.println(errorMessage); + + if(tool.getStrategy() == SyncStateMachine.Strategy.Fresh) { + System.exit(-1); + } + return false; } } @@ -531,6 +569,15 @@ private void syncAndReport() { performCasePurge(mSandbox); if (mLocalUserCredentials != null) { + + if(submissionsEnabled && this.formQueue.size() > 0) { + attemptFormSubmissions(); + if(this.formQueue.size() > 0) { + System.out.println("Cancelling sync until all forms are submitted."); + return; + } + } + System.out.println("Requesting sync..."); restoreUserToSandbox(mSandbox, mSession, mLocalUserCredentials[0], mLocalUserCredentials[1]); @@ -563,4 +610,7 @@ public static void performCasePurge(UserSandbox sandbox) { } } + public void setSubmissionsEnabled() { + submissionsEnabled = true; + } } diff --git a/src/cli/java/org/commcare/util/cli/CliPlayCommand.java b/src/cli/java/org/commcare/util/cli/CliPlayCommand.java index 625a8e8830..d7b4bc48a8 100644 --- a/src/cli/java/org/commcare/util/cli/CliPlayCommand.java +++ b/src/cli/java/org/commcare/util/cli/CliPlayCommand.java @@ -35,7 +35,15 @@ protected Options getOptions() { .required(false) .optionalArg(false) .build(); - options.addOption(restoreFile).addOption(demoUser); + Option submissionEnabled = Option.builder("s") + .argName("SUBMIT_ENABLED") + .desc("Enable Submitting forms to the server") + .longOpt("enable-submission") + .required(false) + .optionalArg(false) + .build(); + + options.addOption(restoreFile).addOption(demoUser).addOption(submissionEnabled); return options; } @@ -59,6 +67,10 @@ public void handle() { CommCareConfigEngine engine = configureApp(resourcePath, prototypeFactory); ApplicationHost host = new ApplicationHost(engine, prototypeFactory); + if(cmd.hasOption("s")) { + host.setSubmissionsEnabled(); + } + if (cmd.hasOption("r")) { host.setRestoreToLocalFile(cmd.getOptionValue("r")); } else if (cmd.hasOption("d")) { diff --git a/src/cli/java/org/commcare/util/mocks/CoreNetworkContext.java b/src/cli/java/org/commcare/util/mocks/CoreNetworkContext.java new file mode 100644 index 0000000000..71ee546066 --- /dev/null +++ b/src/cli/java/org/commcare/util/mocks/CoreNetworkContext.java @@ -0,0 +1,50 @@ +package org.commcare.util.mocks; + +import org.commcare.util.Base64; +import org.javarosa.core.services.PropertyManager; + +import java.net.Authenticator; +import java.net.HttpURLConnection; +import java.net.PasswordAuthentication; +import java.util.HashMap; + +/** + * Created by ctsims on 8/11/2017. + */ + +public class CoreNetworkContext { + String username; + String password; + + public CoreNetworkContext(String username, String password) { + this.username = username; + this.password = password; + } + + public String getFullyQualifiedUsername() { + String domain = PropertyManager.instance().getSingularProperty("cc_user_domain"); + + return username + "@" + domain; + + } + + public void addAuthProperty(HttpURLConnection connection, HashMap properties) { + String encodedCredentials = Base64.encode(String.format("%s:%s", getFullyQualifiedUsername(), password).getBytes()); + String basicAuth = "Basic " + encodedCredentials; + properties.put("Authorization",basicAuth); + } + + public void configureProperties(HttpURLConnection connection, HashMap properties) { + for(String key : properties.keySet()) { + connection.setRequestProperty(key, properties.get(key)); + } + } + + public String getPlainUsername() { + return username; + } + + public String getPassword() { + return new String(password); + } +} diff --git a/src/cli/java/org/commcare/util/mocks/JavaPlatformFormSubmitTool.java b/src/cli/java/org/commcare/util/mocks/JavaPlatformFormSubmitTool.java new file mode 100644 index 0000000000..a2c8179a4f --- /dev/null +++ b/src/cli/java/org/commcare/util/mocks/JavaPlatformFormSubmitTool.java @@ -0,0 +1,98 @@ +package org.commcare.util.mocks; + +import org.commcare.core.interfaces.UserSandbox; +import org.commcare.util.CommCarePlatform; +import org.javarosa.core.io.StreamsUtil; +import org.javarosa.core.services.PropertyManager; +import org.javarosa.core.util.DataUtil; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; + +/** + * Created by ctsims on 8/11/2017. + */ + +public class JavaPlatformFormSubmitTool { + + UserSandbox sandbox; + CommCarePlatform platform; + + CoreNetworkContext context; + + String baseUrl; + String qualifiedUsername; + String password; + + public JavaPlatformFormSubmitTool(UserSandbox sandbox, CoreNetworkContext context) { + this.sandbox = sandbox; + baseUrl = PropertyManager.instance().getSingularProperty("PostURL"); + this.context = context; + } + + public String getSubmitUrl() { + return this.baseUrl; + } + + public boolean submitFormToServer(byte[] serializedForm) { + URL url; + try { + url = new URL(baseUrl); + } catch (MalformedURLException e) { + System.out.println("Invalid submission url: " +baseUrl); + return false; + } + HttpURLConnection conn; + try { + conn = (HttpURLConnection) url.openConnection(); + conn.setDoOutput(true); + + HashMap submissionProperties = new HashMap<>(); + addCommonRequestProperties(submissionProperties); + context.addAuthProperty(conn, submissionProperties); + context.configureProperties(conn, submissionProperties); + + OutputStream postStream = conn.getOutputStream(); + StreamsUtil.writeFromInputToOutput(new ByteArrayInputStream(serializedForm), postStream); + + int responseCode = conn.getResponseCode(); + + if (responseCode >= 200 && responseCode < 300) { + if(responseCode != 200 ) { + System.out.println("Form submission succeeded w/Response code: " + responseCode); + } + return true; + } else if (responseCode == 401) { + System.out.println("Incorrect authentication during form submission"); + return false; + } else { + System.out.println("Unexpected response code during form submission: " + responseCode); + return false; + } + + + } catch(IOException ioe) { + System.out.println("Network issue during form submission: " + ioe.getMessage()); + return false; + } + } + + public void addCommonRequestProperties(HashMap properties) { + //TODO: This should get centralized around common requests. + + properties.put("X-OpenRosa-Version", "2.1"); + if (sandbox.getSyncToken() != null) { + properties.put("X-CommCareHQ-LastSyncToken", sandbox.getSyncToken()); + } + properties.put("x-openrosa-deviceid", "commcare-mock-utility"); + + } + + + +} diff --git a/src/cli/java/org/commcare/util/mocks/JavaPlatformSyncTool.java b/src/cli/java/org/commcare/util/mocks/JavaPlatformSyncTool.java new file mode 100644 index 0000000000..9b2e46bd3b --- /dev/null +++ b/src/cli/java/org/commcare/util/mocks/JavaPlatformSyncTool.java @@ -0,0 +1,109 @@ +package org.commcare.util.mocks; + +import org.commcare.core.interfaces.UserSandbox; +import org.commcare.core.parse.ParseUtils; +import org.commcare.modern.session.SessionWrapper; +import org.javarosa.core.io.StreamsUtil; +import org.javarosa.core.model.User; +import org.javarosa.core.services.storage.IStorageIterator; +import org.javarosa.xml.util.InvalidStructureException; +import org.javarosa.xml.util.UnfullfilledRequirementsException; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.Authenticator; +import java.net.HttpURLConnection; +import java.net.PasswordAuthentication; +import java.net.URL; +import java.util.HashMap; + +/** + * Created by ctsims on 8/11/2017. + */ + +public class JavaPlatformSyncTool extends SyncStateMachine { + + public boolean isRecoverySupported = false; + CoreNetworkContext context; + + public JavaPlatformSyncTool(String username, String password, UserSandbox sandbox, + SessionWrapper session) { + super(username, password, sandbox, session); + context = new CoreNetworkContext(username, password); + } + + @Override + protected void performPlatformRequest(String urlToUse) { + System.out.println(String.format("Request Triggered [Phase: %s]: URL - %s", state.toString(), nextUrlToUse)); + + //Go get our sandbox! + try { + URL url = new URL(nextUrlToUse); + HttpURLConnection conn = getHttpConnection(url); + System.out.println(String.format("Response: %d", conn.getResponseCode())); + if (conn.getResponseCode() == 412) { + this.state = State.Recovery_Requested; + } else if (conn.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) { + unrecoverableError(Error.Invalid_Credentials); + } else if (conn.getResponseCode() == 200) { + payload = this.generatePayloadFromConnection(conn); + this.state = State.Payload_Received; + } else if (conn.getResponseCode() == 202) { + this.state = State.Waiting_For_Progress; + } else { + unrecoverableError(Error.Unexpected_Server_Response_Code); + this.unexpectedResponseCode = conn.getResponseCode(); + } + } catch (IOException e) { + recoverableError(Error.Network_Error); + e.printStackTrace(); + } + } + + private HttpURLConnection getHttpConnection(URL url) throws IOException { + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + HashMap headers = new HashMap<>(); + populateCurrentPlatformConnectionHeaders(headers); + context.addAuthProperty(conn, headers); + context.configureProperties(conn, headers); + return conn; + } + + private byte[] generatePayloadFromConnection(HttpURLConnection conn) throws IOException { + BufferedInputStream bis = new BufferedInputStream(conn.getInputStream()); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + StreamsUtil.writeFromInputToOutput(bis, bos); + return bos.toByteArray(); + } + + public void processPlatformPayload(boolean inRecoveryMode){ + if(inRecoveryMode) { + unrecoverableError("The Java Platform Sync cannot yet handle 412 recovery events", + Error.Unhandled_Situation); + return; + } + + try { + ParseUtils.parseIntoSandbox(new ByteArrayInputStream(payload), sandbox); + this.state = State.Success; + } catch (XmlPullParserException | UnfullfilledRequirementsException | + InvalidStructureException | IOException e) { + e.printStackTrace(); + unrecoverableError(Error.Invalid_Payload); + } + } + + protected void processPlatformWaitSignal() { + //TODO: This is not correct + System.out.println("Received a 202, waiting for 5 seconds before retrying"); + try { + Thread.sleep(5000); + } catch (InterruptedException e) { + e.printStackTrace(); + } + state = SyncStateMachine.State.Ready_For_Request; + } +} diff --git a/src/cli/java/org/commcare/util/mocks/SyncStateMachine.java b/src/cli/java/org/commcare/util/mocks/SyncStateMachine.java new file mode 100644 index 0000000000..2c8ea7d354 --- /dev/null +++ b/src/cli/java/org/commcare/util/mocks/SyncStateMachine.java @@ -0,0 +1,266 @@ +package org.commcare.util.mocks; + +import org.commcare.cases.util.CaseDBUtils; +import org.commcare.core.interfaces.UserSandbox; +import org.commcare.modern.session.SessionWrapper; +import org.javarosa.core.services.PropertyManager; + +import java.util.HashMap; +import java.util.HashSet; + +/** + * Created by ctsims on 8/11/2017. + */ + +public abstract class SyncStateMachine { + UserSandbox sandbox; + SessionWrapper session; + + State state; + State stateBeforeError; + Strategy strategy; + + String otaFreshRestoreUrl; + String otaSyncUrl; + String nextUrlToUse; + + String domain; + + Error error; + String errorMessage; + + byte[] payload; + + //TODO: Error should be typed and come with metadata. + public int getServerResponseCode() { + return unexpectedResponseCode; + } + + public String getErrorMessage() { + return errorMessage; + } + + public enum State { + Waiting_For_Init, + + Ready_For_Request, + + Waiting_For_Progress, + + Recovery_Requested, + + Payload_Received, + + Success, + + Unrecoverable_Error, + Recoverable_Error + } + + public enum Strategy { + Fresh, + Incremental, + Recovery + } + + public enum Error { + Invalid_Credentials, + Unexpected_Server_Response_Code, + Network_Error, + Invalid_Payload, + Unhandled_Situation + } + + int unexpectedResponseCode; + + public SyncStateMachine(String username, String password, UserSandbox sandbox, + SessionWrapper session) { + this.sandbox = sandbox; + this.session = session; + this.state = State.Waiting_For_Init; + } + + public State getCurrentState() { + return state; + } + + public Strategy getStrategy() { + return strategy; + } + + public void initialize() throws SyncErrorException { + if(state != State.Waiting_For_Init) { + throw getInvalidStateException("Initialize"); + } + + //TODO: Figure out how much of this should be platform; + + String urlStateParams = ""; + + boolean incremental = false; + + if (sandbox.getLoggedInUser() != null) { + String syncToken = sandbox.getSyncToken(); + String caseStateHash = CaseDBUtils.computeCaseDbHash(sandbox.getCaseStorage()); + + urlStateParams = String.format("&since=%s&state=ccsh:%s", syncToken, caseStateHash); + incremental = true; + + System.out.println(String.format( + "\nIncremental sync requested. \nSync Token: %s\nState Hash: %s", + syncToken, caseStateHash)); + } + + //fetch the restore data and set credentials + otaFreshRestoreUrl = PropertyManager.instance().getSingularProperty("ota-restore-url") + + "?version=2.0"; + + otaSyncUrl = otaFreshRestoreUrl + urlStateParams; + + //TODO: This stuff should migrate out in a restructure to the platform side + + domain = PropertyManager.instance().getSingularProperty("cc_user_domain"); + + if(incremental) { + strategy = Strategy.Incremental; + } else { + strategy = Strategy.Fresh; + } + + nextUrlToUse = strategy == Strategy.Fresh ? otaFreshRestoreUrl : otaSyncUrl; + + state = State.Ready_For_Request; + } + + protected void populateCurrentPlatformConnectionHeaders(HashMap properties) { + //TODO: This should get centralized around common requests. + + properties.put("X-OpenRosa-Version", "2.1"); + if (sandbox.getSyncToken() != null) { + properties.put("X-CommCareHQ-LastSyncToken", sandbox.getSyncToken()); + } + properties.put("x-openrosa-deviceid", "commcare-mock-utility"); + } + + /** + * @Outcomes: Waiting_For_Progress, Recovery_Requested, Payload_Received + */ + public final void performRequest() throws SyncErrorException { + if(state != State.Ready_For_Request && state != State.Waiting_For_Progress) { + throw getInvalidStateException("Perform Request"); + } + + performPlatformRequest(nextUrlToUse); + } + + protected abstract void performPlatformRequest(String url); + + /** + * Incoming States: Waiting_For_Progress + * Outgoing States: Ready_For_Request + */ + public final void processWaitSignal() throws SyncErrorException { + if(state != State.Waiting_For_Progress) { + throw getInvalidStateException("Waiting for Progress"); + } + processPlatformWaitSignal(); + } + + protected abstract void processPlatformWaitSignal(); + + + public void resetFromError() throws SyncErrorException { + if(state != State.Recoverable_Error) { + throw getInvalidStateException("Recover from Error"); + } + this.state = stateBeforeError; + this.stateBeforeError = null; + } + + protected void unrecoverableError(Error errorCode) { + unrecoverableError(null, errorCode); + } + + protected void unrecoverableError(String message, Error errorCode) { + this.stateBeforeError = this.state; + this.errorMessage = message; + this.state = State.Unrecoverable_Error; + + this.error = errorCode; + } + + protected void recoverableError(Error errorCode) { + this.stateBeforeError = this.state; + this.state = State.Unrecoverable_Error; + + this.error = errorCode; + } + + + public void transitionToRecoveryStrategy() throws SyncErrorException { + if(state != State.Recovery_Requested) { + throw getInvalidStateException("Transition to Recovery Mode"); + } + + this.strategy = Strategy.Recovery; + this.nextUrlToUse = this.otaFreshRestoreUrl; + this.state = State.Ready_For_Request; + } + + public final void processPayload() throws SyncErrorException { + if(state != State.Payload_Received) { + throw getInvalidStateException("Process Payload"); + } + + processPlatformPayload(strategy == Strategy.Recovery); + + if(this.getCurrentState() != State.Success) { + throw getInvalidStateException("process payload must succeed or set an unrecoverable error"); + } + } + + protected abstract void processPlatformPayload(boolean inRecoveryMode); + + + protected InvalidStateException getInvalidStateException(String action) throws SyncErrorException { + if(state == State.Unrecoverable_Error) { + throw new SyncErrorException(errorMessage, this.stateBeforeError, this.error); + } else { + return new InvalidStateException( + String.format("Action [%s] is incompatible with the current sync state: %s", + action, state)); + } + } + + public static class SyncErrorException extends Exception { + SyncStateMachine.State stateBeforeError; + Error error; + + public SyncErrorException(String message, State stateBeforeError, Error error) { + super(message); + this.stateBeforeError = stateBeforeError; + this.error = error; + } + + public SyncErrorException(State stateBeforeError, Error error) { + this(null, stateBeforeError, error); + } + + public State getStateBeforeError() { + return stateBeforeError; + } + + public Error getSyncError() { + return error; + } + } + + + public static class InvalidStateException extends RuntimeException { + public InvalidStateException(String msg) { + super(msg); + } + } + + +} diff --git a/src/test/java/org/commcare/mockapp/AsyncDataCollisionTests.java b/src/test/java/org/commcare/mockapp/AsyncDataCollisionTests.java new file mode 100644 index 0000000000..18265ff491 --- /dev/null +++ b/src/test/java/org/commcare/mockapp/AsyncDataCollisionTests.java @@ -0,0 +1,164 @@ +package org.commcare.mockapp; + +import org.commcare.backend.suite.model.test.EmptyAppElementsTests; +import org.commcare.modern.session.SessionWrapper; +import org.commcare.session.SessionNavigator; +import org.commcare.suite.model.MenuDisplayable; +import org.commcare.suite.model.MenuLoader; +import org.commcare.test.utilities.MockApp; +import org.commcare.test.utilities.MockSessionNavigationResponder; +import org.commcare.test.utilities.XFormTestUtilities; +import org.commcare.util.LoggerInterface; +import org.commcare.util.mocks.JavaPlatformFormSubmitTool; +import org.commcare.util.mocks.JavaPlatformSyncTool; +import org.javarosa.core.model.QuestionDef; +import org.javarosa.core.model.data.IntegerData; +import org.javarosa.core.util.externalizable.LivePrototypeFactory; +import org.javarosa.form.api.FormEntryController; +import org.javarosa.form.api.FormEntryPrompt; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.text.Normalizer; + +import static org.junit.Assert.assertEquals; + +/** + * Created by ctsims on 8/10/2017. + */ + +public class AsyncDataCollisionTests { + + private MockApp mockAppUser1; + private MockApp mockAppUser2; + + private SessionNavigator app1SessionNavigator; + private SessionNavigator app2SessionNavigator; + + + @Before + public void setUp() throws Exception { + LivePrototypeFactory sharedFactory = new LivePrototypeFactory(); + + mockAppUser1 = new MockApp("/app_data_collision/", null, sharedFactory); + mockAppUser1.performInitialUserRestoreFromNetwork("test.sync.a","123"); + + MockSessionNavigationResponder mockSessionNavigationResponder = + new MockSessionNavigationResponder(mockAppUser1.getSession()); + app1SessionNavigator = new SessionNavigator(mockSessionNavigationResponder); + + + mockAppUser2 = new MockApp("/app_data_collision/", null, sharedFactory); + mockAppUser2.performInitialUserRestoreFromNetwork("test.sync.b", "123"); + + MockSessionNavigationResponder mockSessionNavigationResponder2 = + new MockSessionNavigationResponder(mockAppUser2.getSession()); + app2SessionNavigator = new SessionNavigator(mockSessionNavigationResponder2); + + } + + @Test + public void testDataCollision() { + setValueToNumber(app1SessionNavigator, mockAppUser1, 0); + + mockAppUser2.syncData(); + + ensureDataIsSetToNumber(app2SessionNavigator, mockAppUser2,0); + + setValueToNumber(app1SessionNavigator, mockAppUser1, 1); + + mockAppUser2.syncData(); + + setValueToNumber(app2SessionNavigator, mockAppUser2, 2); + + triggerSyncRequire202ThenNeverComplete(mockAppUser1.getSyncTool()); + + setValueToNumber(app1SessionNavigator, mockAppUser1, 5); + + mockAppUser1.syncData(); + + ensureDataIsSetToNumber(app1SessionNavigator, mockAppUser1,5); + + } + + private void triggerSyncRequire202ThenNeverComplete(JavaPlatformSyncTool tool) { + try { + tool.initialize(); + + while (tool.getCurrentState() == org.commcare.util.mocks.SyncStateMachine.State.Ready_For_Request) { + tool.performRequest(); + + switch (tool.getCurrentState()) { + case Waiting_For_Progress: + return; + case Recovery_Requested: + throw new RuntimeException("Unexpected State during tests"); + case Recoverable_Error: + tool.resetFromError(); + break; + } + } + throw new RuntimeException("Test failed due to lack of asynchronous sync response"); + + } catch(org.commcare.util.mocks.SyncStateMachine.SyncErrorException e) { + throw new RuntimeException("Error Syncing during tests"); + } + + } + + private void ensureDataIsSetToNumber(SessionNavigator navigator, MockApp app, int value) { + FormEntryController fec = navigateSessionToForm(navigator, app); + SessionWrapper session = app.getSession(); + + fec.stepToNextEvent(); + + FormEntryPrompt prompt = fec.getQuestionPrompts()[0]; + Assert.assertEquals(String.format("The current value is:%d",value), prompt.getQuestionText()); + + session.clearAllState(); + session.clearVolitiles(); + } + + private void setValueToNumber(SessionNavigator navigator, MockApp app, int value) { + FormEntryController fec = navigateSessionToForm(navigator, app); + SessionWrapper session = app.getSession(); + + fec.stepToNextEvent(); + + fec.stepToNextEvent(); + + fec.answerQuestion(new IntegerData(value)); + + byte[] form = XFormTestUtilities.finalizeAndSerializeForm(fec); + + app.processForm(form); + + app.submitForm(form); + + session.clearAllState(); + session.clearVolitiles(); + + } + + private FormEntryController navigateSessionToForm(SessionNavigator navigator, MockApp app) { + SessionWrapper session = app.getSession(); + + navigator.startNextSessionStep(); + + session.setCommand("m1"); + + navigator.startNextSessionStep(); + + session.setDatum("case_id", "56792937-d93e-465c-8619-5d75e98a6c88"); + navigator.startNextSessionStep(); + session.setCommand("m0-f0"); + navigator.startNextSessionStep(); + + FormEntryController fec = app.loadAndInitForm("modules-1/forms-0.xml"); + + assertEquals(FormEntryController.EVENT_BEGINNING_OF_FORM, fec.getModel().getEvent()); + + return fec; + } +} diff --git a/src/test/java/org/commcare/test/utilities/JavaPlatformSyncTool.java b/src/test/java/org/commcare/test/utilities/JavaPlatformSyncTool.java new file mode 100644 index 0000000000..f657f4dd6f --- /dev/null +++ b/src/test/java/org/commcare/test/utilities/JavaPlatformSyncTool.java @@ -0,0 +1,93 @@ +package org.commcare.test.utilities; + +import org.commcare.core.interfaces.UserSandbox; +import org.commcare.core.parse.ParseUtils; +import org.commcare.modern.session.SessionWrapper; +import org.javarosa.core.io.StreamsUtil; +import org.javarosa.core.model.User; +import org.javarosa.core.services.storage.IStorageIterator; +import org.javarosa.xml.util.InvalidStructureException; +import org.javarosa.xml.util.UnfullfilledRequirementsException; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.Authenticator; +import java.net.HttpURLConnection; +import java.net.PasswordAuthentication; +import java.net.URL; + +/** + * Created by ctsims on 8/11/2017. + */ + +public class JavaPlatformSyncTool extends SyncStateMachine { + + public JavaPlatformSyncTool(String username, String password, UserSandbox sandbox, + SessionWrapper session) { + super(username, password, sandbox, session); + } + + @Override + public void setUpAuthentication(final String qualifiedUsername, final char[] password) { + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(qualifiedUsername, password); + } + }); + + } + + @Override + protected void performPlatformRequest(String urlToUse) { + System.out.println(String.format("Request Triggered [Phase: %s]: URL - %s", state.toString(), nextUrlToUse)); + + //Go get our sandbox! + try { + + URL url = new URL(nextUrlToUse); + HttpURLConnection conn = (HttpURLConnection)url.openConnection(); + + if (conn.getResponseCode() == 412) { + this.state = State.Recovery_Requested; + } else if (conn.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED) { + unrecoverableError(Error.Invalid_Credentials); + } else if (conn.getResponseCode() == 200) { + payload = this.generatePayloadFromConnection(conn); + this.state = State.Payload_Received; + } else if (conn.getResponseCode() == 202) { + this.state = State.Waiting_For_Progress; + } else { + unrecoverableError(Error.Unexpected_Response); + this.unexpectedResponseCode = conn.getResponseCode(); + } + } catch (IOException e) { + recoverableError(Error.Network_Error); + e.printStackTrace(); + } + } + + private byte[] generatePayloadFromConnection(HttpURLConnection conn) throws IOException { + BufferedInputStream bis = new BufferedInputStream(conn.getInputStream()); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + StreamsUtil.writeFromInputToOutput(bis, bos); + return bos.toByteArray(); + } + + public void processPlatformPayload(boolean inRecoveryMode){ + if(inRecoveryMode) { + throw new RuntimeException("Cannot currently perform recovery on the mock platform sync tool"); + } + + try { + ParseUtils.parseIntoSandbox(new ByteArrayInputStream(payload), sandbox); + } catch (XmlPullParserException | UnfullfilledRequirementsException | + InvalidStructureException | IOException e) { + e.printStackTrace(); + unrecoverableError(Error.Invalid_Payload); + } + } +} diff --git a/src/test/java/org/commcare/test/utilities/MockApp.java b/src/test/java/org/commcare/test/utilities/MockApp.java index 17c7b72378..c21baa57c2 100644 --- a/src/test/java/org/commcare/test/utilities/MockApp.java +++ b/src/test/java/org/commcare/test/utilities/MockApp.java @@ -1,18 +1,30 @@ package org.commcare.test.utilities; +import org.commcare.core.interfaces.UserSandbox; +import org.commcare.core.parse.CommCareTransactionParserFactory; +import org.commcare.data.xml.DataModelPullParser; import org.commcare.modern.session.SessionWrapper; import org.commcare.core.parse.ParseUtils; import org.commcare.util.engine.CommCareConfigEngine; -import org.commcare.util.mocks.MockUserDataSandbox; +import org.commcare.util.mocks.*; +import org.commcare.util.mocks.JavaPlatformSyncTool; +import org.commcare.util.mocks.SyncStateMachine; import org.javarosa.core.model.FormDef; import org.javarosa.core.model.FormIndex; +import org.javarosa.core.model.User; import org.javarosa.core.services.storage.IStorageIndexedFactory; +import org.javarosa.core.services.storage.IStorageIterator; import org.javarosa.core.services.storage.IStorageUtilityIndexed; import org.javarosa.core.services.storage.util.DummyIndexedStorageUtility; import org.javarosa.core.test.FormParseInit; import org.javarosa.core.util.externalizable.LivePrototypeFactory; +import org.javarosa.core.util.externalizable.PrototypeFactory; import org.javarosa.form.api.FormEntryController; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + /** * A mock app is a quick test wrapper that makes it easy to start playing with a live instance * of a CommCare app including the session and user data that goes along with it. @@ -26,6 +38,8 @@ public class MockApp { private final SessionWrapper mSessionWrapper; private final String APP_BASE; + CoreNetworkContext context; + /** * Creates and initializes a mockapp that is located at the provided Java Resource path. * @@ -33,11 +47,23 @@ public class MockApp { * trailing slash, like /path/app/ */ public MockApp(String resourcePath) throws Exception { + this(resourcePath, "user_restore.xml", null); + } + + /** + * Creates and initializes a mockapp that is located at the provided Java Resource path. + * + * @param resourcePath The resource path to a an app template. Needs to contain a leading and + * trailing slash, like /path/app/ + * @param userRestorePath The path to a user restore relative to the app template resource path. + * Does not require a leading slash + */ + public MockApp(String resourcePath, String userRestorePath, LivePrototypeFactory factory) throws Exception { if(!(resourcePath.startsWith("/") && resourcePath.endsWith("/"))) { throw new IllegalArgumentException("Invalid resource path for a mock app " + resourcePath); } APP_BASE = resourcePath; - final LivePrototypeFactory mPrototypeFactory = setupStaticStorage(); + final LivePrototypeFactory mPrototypeFactory = factory == null? setupStaticStorage() : factory; CommCareConfigEngine.setStorageFactory(new IStorageIndexedFactory() { @Override public IStorageUtilityIndexed newStorage(String name, Class type) { @@ -49,14 +75,105 @@ public IStorageUtilityIndexed newStorage(String name, Class type) { mEngine.installAppFromReference("jr://resource" + APP_BASE + "profile.ccpr"); mEngine.initEnvironment(); - ParseUtils.parseIntoSandbox(this.getClass().getResourceAsStream(APP_BASE + "user_restore.xml"), mSandbox); - //If we parsed in a user, arbitrarily log one in. - mSandbox.setLoggedInUser(mSandbox.getUserStorage().read(0)); + if(userRestorePath != null) { + ParseUtils.parseIntoSandbox(this.getClass().getResourceAsStream(APP_BASE + userRestorePath), mSandbox); + + //If we parsed in a user, arbitrarily log one in. + mSandbox.setLoggedInUser(mSandbox.getUserStorage().read(0)); + } mSessionWrapper = new SessionWrapper(mEngine.getPlatform(), mSandbox); } + public void performInitialUserRestoreFromNetwork(String username, String password) { + context = new CoreNetworkContext(username, password); + + syncData(); + + UserSandbox sandbox = mSessionWrapper.getSandbox(); + + //Initialize our User + for (IStorageIterator iterator = sandbox.getUserStorage().iterate(); iterator.hasMore(); ) { + User u = iterator.nextRecord(); + if (username.equalsIgnoreCase(u.getUsername())) { + sandbox.setLoggedInUser(u); + } + } + + if (mSessionWrapper != null) { + // old session data is now no longer valid + mSessionWrapper.clearVolitiles(); + } + + } + + public JavaPlatformSyncTool getSyncTool() { + UserSandbox sandbox = mSessionWrapper.getSandbox(); + return new org.commcare.util.mocks.JavaPlatformSyncTool(context.getPlainUsername(), context.getPassword(), + sandbox, mSessionWrapper); + + } + + public void syncData() { + org.commcare.util.mocks.JavaPlatformSyncTool tool = getSyncTool(); + + attemptSync(tool); + + } + + private static void attemptSync(JavaPlatformSyncTool tool) { + try { + tool.initialize(); + + while (tool.getCurrentState() == org.commcare.util.mocks.SyncStateMachine.State.Ready_For_Request) { + + tool.performRequest(); + + switch (tool.getCurrentState()) { + case Waiting_For_Progress: + tool.processWaitSignal(); + break; + case Recovery_Requested: + tool.transitionToRecoveryStrategy(); + break; + case Recoverable_Error: + tool.resetFromError(); + break; + } + } + + tool.processPayload(); + } catch(org.commcare.util.mocks.SyncStateMachine.SyncErrorException e) { + throw new RuntimeException("Couldn't sync initial user data"); + } + } + + public void processForm(byte[] incomingForm) { + ByteArrayInputStream bais = new ByteArrayInputStream(incomingForm); + try { + DataModelPullParser parser = new DataModelPullParser( + bais, new CommCareTransactionParserFactory(mSessionWrapper.getSandbox()), true, true); + parser.parse(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + try { + bais.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + public void submitForm(byte[] incomingForm) { + JavaPlatformFormSubmitTool submitter = new JavaPlatformFormSubmitTool(mSessionWrapper.getSandbox(), context); + if(!submitter.submitFormToServer(incomingForm)) { + throw new RuntimeException("Error submitting form during tests"); + } + } + + /** * Loads the provided form and properly initializes external data instances, * such as the casedb and commcare session. diff --git a/src/test/java/org/commcare/test/utilities/SyncStateMachine.java b/src/test/java/org/commcare/test/utilities/SyncStateMachine.java new file mode 100644 index 0000000000..8b7a405518 --- /dev/null +++ b/src/test/java/org/commcare/test/utilities/SyncStateMachine.java @@ -0,0 +1,190 @@ +package org.commcare.test.utilities; + +import org.commcare.cases.util.CaseDBUtils; +import org.commcare.core.interfaces.UserSandbox; +import org.commcare.core.parse.ParseUtils; +import org.commcare.modern.session.SessionWrapper; +import org.javarosa.core.io.StreamsUtil; +import org.javarosa.core.services.PropertyManager; +import org.javarosa.xml.util.InvalidStructureException; +import org.javarosa.xml.util.UnfullfilledRequirementsException; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * Created by ctsims on 8/11/2017. + */ + +public abstract class SyncStateMachine { + String username; + String password; + UserSandbox sandbox; + SessionWrapper session; + + State state; + State stateBeforeError; + Strategy strategy; + + String otaFreshRestoreUrl; + String otaSyncUrl; + String nextUrlToUse; + + String domain; + + Error error; + + byte[] payload; + + enum State { + Waiting_For_Init, + + Ready_For_Request, + + Waiting_For_Progress, + + Recovery_Requested, + + Payload_Received, + Payload_Processed, + + Unrecoverable_Error, + Recoverable_Error + } + + enum Strategy { + Fresh, + Incremental, + Recovery + } + + enum Error { + Invalid_Credentials, + Unexpected_Response, + Network_Error, + Invalid_Payload + } + + int unexpectedResponseCode; + + public SyncStateMachine(String username, String password, UserSandbox sandbox, + SessionWrapper session) { + this.username = username; + this.password = password; + this.sandbox = sandbox; + this.session = session; + } + + public State getCurrentState() { + return state; + } + + public void initialize() throws InvalidStateException { + if(state != State.Waiting_For_Init) { + throw new InvalidStateException(); + } + + String urlStateParams = ""; + + boolean incremental = false; + + if (sandbox.getLoggedInUser() != null) { + String syncToken = sandbox.getSyncToken(); + String caseStateHash = CaseDBUtils.computeCaseDbHash(sandbox.getCaseStorage()); + + urlStateParams = String.format("&since=%s&state=ccsh:%s", syncToken, caseStateHash); + incremental = true; + + System.out.println(String.format( + "\nIncremental sync requested. \nSync Token: %s\nState Hash: %s", + syncToken, caseStateHash)); + } + + //fetch the restore data and set credentials + otaFreshRestoreUrl = PropertyManager.instance().getSingularProperty("ota-restore-url") + + "?version=2.0"; + + otaSyncUrl = otaFreshRestoreUrl + urlStateParams; + + domain = PropertyManager.instance().getSingularProperty("cc_user_domain"); + final String qualifiedUsername = username + "@" + domain; + + setUpAuthentication(qualifiedUsername, password.toCharArray()); + + + if(incremental) { + strategy = Strategy.Incremental; + } else { + strategy = Strategy.Fresh; + } + + nextUrlToUse = strategy == Strategy.Fresh ? otaFreshRestoreUrl : otaSyncUrl; + + state = State.Ready_For_Request; + } + + public abstract void setUpAuthentication(final String qualifiedUsername, final char[] password); + + public final void performRequest() throws InvalidStateException { + if(state != State.Ready_For_Request || state != State.Waiting_For_Progress) { + throw new InvalidStateException(); + } + + System.out.println(String.format("Request Triggered [Phase: %s]: URL - %s", state.toString(), nextUrlToUse)); + performPlatformRequest(nextUrlToUse); + + } + + protected abstract void performPlatformRequest(String url); + + public void resetFromError() throws InvalidStateException { + if(state != State.Recoverable_Error) { + throw new InvalidStateException(); + } + this.state = stateBeforeError; + this.stateBeforeError = null; + } + + protected void unrecoverableError(Error errorCode) { + this.stateBeforeError = this.state; + this.state = State.Unrecoverable_Error; + + this.error = errorCode; + } + + protected void recoverableError(Error errorCode) { + this.stateBeforeError = this.state; + this.state = State.Unrecoverable_Error; + + this.error = errorCode; + } + + + public void transitionToRecoveryStrategy() throws InvalidStateException { + if(state != State.Recovery_Requested) { throw new InvalidStateException(); } + + this.strategy = Strategy.Recovery; + this.nextUrlToUse = this.otaFreshRestoreUrl; + this.state = State.Ready_For_Request; + } + + public final void processPayload() throws InvalidStateException { + if(state != State.Payload_Received) { + throw new InvalidStateException(); + } + + + processPlatformPayload(strategy == Strategy.Recovery); + } + + protected abstract void processPlatformPayload(boolean inRecoveryMode); + + public static class InvalidStateException extends Exception { + + } +} diff --git a/src/test/java/org/commcare/test/utilities/XFormTestUtilities.java b/src/test/java/org/commcare/test/utilities/XFormTestUtilities.java new file mode 100644 index 0000000000..4285c5e356 --- /dev/null +++ b/src/test/java/org/commcare/test/utilities/XFormTestUtilities.java @@ -0,0 +1,29 @@ +package org.commcare.test.utilities; + +import org.javarosa.engine.XFormPlayer; +import org.javarosa.form.api.FormEntryController; +import org.javarosa.model.xform.XFormSerializingVisitor; + +import java.io.IOException; + +/** + * Created by ctsims on 8/11/2017. + */ + +public class XFormTestUtilities { + + public static byte[] finalizeAndSerializeForm(FormEntryController fec) { + + fec.getModel().getForm().postProcessInstance(); + + XFormSerializingVisitor visitor = new XFormSerializingVisitor(); + try { + return visitor.serializeInstance(fec.getModel().getForm().getInstance()); + } catch (IOException e) { + RuntimeException re = new RuntimeException("Couldn't serialize form during tests"); + re.initCause(e); + throw re; + } + } + +} diff --git a/src/test/resources/app_data_collision/default/app_strings.txt b/src/test/resources/app_data_collision/default/app_strings.txt new file mode 100644 index 0000000000..b024c3799e --- /dev/null +++ b/src/test/resources/app_data_collision/default/app_strings.txt @@ -0,0 +1,28 @@ +app.display.name=Replication: Sync Overwrite +case_autoload.case.case_missing=Unable to find case referenced by auto-select case ID. +case_autoload.case.property_missing=The case index specified for case auto-selecting could not be found: ${0} +case_autoload.fixture.case_missing=Unable to find case referenced by auto-select case ID. +case_autoload.fixture.exactly_one_fixture=The lookup table settings for your user are incorrect. This user must have access to exactly one lookup table row for the table: ${0} +case_autoload.fixture.property_missing=The lookup table field specified for case auto-selecting could not be found: ${0} +case_autoload.location.case_missing=This form requires the user's location to be marked as 'Tracks Stock'. +case_autoload.location.property_missing=This form requires access to the user's location, but none was found. +case_autoload.raw.case_missing=Unable to find case referenced by auto-select case ID. +case_autoload.raw.property_missing=The custom xpath expression specified for case auto-selecting could not be found: ${0} +case_autoload.user.case_missing=Unable to find case referenced by auto-select case ID. +case_autoload.user.property_missing=The user data key specified for case auto-selecting could not be found: ${0} +case_autoload.usercase.case_missing=Unable to find case referenced by auto-select case ID. +case_autoload.usercase.property_missing=The user case specified for case auto-selecting could not be found: ${0} +case_sharing.exactly_one_group=The case sharing settings for your user are incorrect. This user must be in exactly one case sharing group. Please contact your supervisor. +cchq.case=Case +cchq.referral=Referral +en=English +forms.m0f0=Registration +forms.m1f0=Visit +forms.m1f1=Close +homescreen.title=Replication: Sync Overwrite +lang.current=en +m0.case_long.case_name_1.header=Name +m0.case_short.case_name_1.header=Name +m1.case_short.case_name_1.header=Name +modules.m0=Registration +modules.m1=Follow Up diff --git a/src/test/resources/app_data_collision/en/app_strings.txt b/src/test/resources/app_data_collision/en/app_strings.txt new file mode 100644 index 0000000000..18edff39be --- /dev/null +++ b/src/test/resources/app_data_collision/en/app_strings.txt @@ -0,0 +1,13 @@ +app.display.name=Replication: Sync Overwrite +cchq.case=Case +cchq.referral=Referral +forms.m0f0=Registration +forms.m1f0=Visit +forms.m1f1=Close +homescreen.title=Replication: Sync Overwrite +lang.current=en +m0.case_long.case_name_1.header=Name +m0.case_short.case_name_1.header=Name +m1.case_short.case_name_1.header=Name +modules.m0=Registration +modules.m1=Follow Up diff --git a/src/test/resources/app_data_collision/media_suite.xml b/src/test/resources/app_data_collision/media_suite.xml new file mode 100644 index 0000000000..8e9af2924e --- /dev/null +++ b/src/test/resources/app_data_collision/media_suite.xml @@ -0,0 +1,2 @@ + + diff --git a/src/test/resources/app_data_collision/modules-0/forms-0.xml b/src/test/resources/app_data_collision/modules-0/forms-0.xml new file mode 100644 index 0000000000..989a8795f6 --- /dev/null +++ b/src/test/resources/app_data_collision/modules-0/forms-0.xml @@ -0,0 +1,25 @@ + + + Registration + + + + + case + + + + + + Name + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/app_data_collision/modules-1/forms-0.xml b/src/test/resources/app_data_collision/modules-1/forms-0.xml new file mode 100644 index 0000000000..a4607ff405 --- /dev/null +++ b/src/test/resources/app_data_collision/modules-1/forms-0.xml @@ -0,0 +1,41 @@ + + + Visit + + + + + + + + + + + + + + + + + + + The current value is: + + + Enter the New Value + + + + + {"#case/number_value":null} + {"prefixes":{"#case/":"instance('casedb')/casedb/case[@case_id = instance('commcaresession')/session/data/case_id]/"}} + + + + + + + \ No newline at end of file diff --git a/src/test/resources/app_data_collision/modules-1/forms-1.xml b/src/test/resources/app_data_collision/modules-1/forms-1.xml new file mode 100644 index 0000000000..afb0a67d1c --- /dev/null +++ b/src/test/resources/app_data_collision/modules-1/forms-1.xml @@ -0,0 +1,78 @@ + + + Close + + + + + + + + + + + + + + + This is a case close form. It lets you select a previously registered case and remove it from the phone. Its important to remove cases from the phone so that the user only sees the cases that they need to see. However, all information collected about the case will still be stored on CommCareHQ. + +* You can add questions to collect information about why the case is being closed. +* Previously saved case properties have already been automatically loaded to this form, and can be updated and referenced in a similar fashion to the "Visit" form. + +For more help, see the following Help Site Pages: + +* [CommCare Forms](https://confluence.dimagi.com/display/commcarepublic/Form+Builder) +* [Case Management Overview](https://confluence.dimagi.com/display/commcarepublic/Case+Management). +* [Application Building Tutorials](https://confluence.dimagi.com/display/commcarepublic/Application+Building+Tutorial+Series) + +When you're ready to test your application, you can use the Try In CloudCare option on the form settings page, or [install it on a phone using our deploy manager](https://confluence.dimagi.com/display/commcarepublic/Deploy+an+Application+on+CommCareHQ). + This is a case close form. It lets you select a previously registered case and remove it from the phone. Its important to remove cases from the phone so that the user only sees the cases that they need to see. However, all information collected about the case will still be stored on CommCareHQ. + +* You can add questions to collect information about why the case is being closed. +* Previously saved case properties have already been automatically loaded to this form, and can be updated and referenced in a similar fashion to the "Visit" form. + +For more help, see the following Help Site Pages: + +* [CommCare Forms](https://confluence.dimagi.com/display/commcarepublic/Form+Builder) +* [Case Management Overview](https://confluence.dimagi.com/display/commcarepublic/Case+Management). +* [Application Building Tutorials](https://confluence.dimagi.com/display/commcarepublic/Application+Building+Tutorial+Series) + +When you're ready to test your application, you can use the Try In CloudCare option on the form settings page, or [install it on a phone using our deploy manager](https://confluence.dimagi.com/display/commcarepublic/Deploy+an+Application+on+CommCareHQ). + + + Confirm case close. Choose "Yes" to confirm that you want to close this case. + + + Yes + + + No + + + Close Reason + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/app_data_collision/profile.ccpr b/src/test/resources/app_data_collision/profile.ccpr new file mode 100644 index 0000000000..14d2bbd773 --- /dev/null +++ b/src/test/resources/app_data_collision/profile.ccpr @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ./suite.xml + + https://www.commcarehq.org/a/corpora/apps/download/a54875c0e70ed77f384c27afbaac26ce/suite.xml + + + + + ./media_suite.xml + + https://www.commcarehq.org/a/corpora/apps/download/a54875c0e70ed77f384c27afbaac26ce/media_suite.xml + + + diff --git a/src/test/resources/app_data_collision/suite.xml b/src/test/resources/app_data_collision/suite.xml new file mode 100644 index 0000000000..7bf7e0f0c7 --- /dev/null +++ b/src/test/resources/app_data_collision/suite.xml @@ -0,0 +1,163 @@ + + + + + ./modules-0/forms-0.xml + ./modules-0/forms-0.xml + + + + + ./modules-1/forms-0.xml + ./modules-1/forms-0.xml + + + + + ./modules-1/forms-1.xml + ./modules-1/forms-1.xml + + + + + ./default/app_strings.txt + ./default/app_strings.txt + + + + + ./en/app_strings.txt + ./en/app_strings.txt + + + + + <text> + <locale id="cchq.case"/> + </text> + + +
+ + + +
+ + + + + + +
+
+ + + <text> + <locale id="cchq.case"/> + </text> + + +
+ + + +
+ +
+
+ + + <text> + <locale id="cchq.case"/> + </text> + + +
+ + + +
+ + + + + + +
+
+ +
http://openrosa.org/formdesigner/11FAC65A-F2CD-427F-A870-CF126336AAB5
+ + + + + + + + + + + + + + + + +
+ +
http://openrosa.org/formdesigner/52D111C9-79C6-403F-BF4C-D24B64A872E2
+ + + + + + + + + +
+ +
http://openrosa.org/formdesigner/5CCB1614-68B3-44C0-A166-D63AA7C1D4FB
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + Demo Group + + + +
diff --git a/src/translate/java/org/javarosa/engine/XFormPlayer.java b/src/translate/java/org/javarosa/engine/XFormPlayer.java index 571261a766..9a452772bf 100644 --- a/src/translate/java/org/javarosa/engine/XFormPlayer.java +++ b/src/translate/java/org/javarosa/engine/XFormPlayer.java @@ -249,6 +249,10 @@ public InputStream getResultStream() { return new ByteArrayInputStream(mExecutionInstance); } + public byte[] getResultBytes() { + return mExecutionInstance; + } + /** * Evaluate input to eval mode, and exit eval mode if * the input is blank.