diff --git a/.gitignore b/.gitignore index cd6997439..65a64d0fe 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ build/ .idea/vcs.xml .idea/workspace.xml .idea/libraries +.idea/runConfigurations projectFilesBackup/ # Eclipse diff --git a/.idea/runConfigurations/Apptentive_Tests.xml b/.idea/runConfigurations/Apptentive_Tests.xml deleted file mode 100644 index ea16eeb5a..000000000 --- a/.idea/runConfigurations/Apptentive_Tests.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..3e1cd0fb3 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,43 @@ +language: android +env: + global: + - SNAPSHOT_REPOSITORY_USERNAME=travis + - SNAPSHOT_REPOSITORY_URL=http://54.183.158.246:8081/artifactory/apptentive-snapshots + - secure: pc2twMw60say0ASdXiJiRAD6tx9Qy82DIMw83qPijB2wyVHLpLbjptqBFyTYy4+JYthZ8xcB5Yretiv//AQS4wdDPsJNwOKUGXamm8IBx+1wnhG/R/ROz67Ibj4XWHIX24GaKN/MD8tCN9VPdeNEL1jysSEVxqqsvOGBsxitAyI= +jdk: + - oraclejdk8 +before_cache: + - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock + - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ +cache: + directories: + - $HOME/.gradle/caches/ + - $HOME/.gradle/wrapper/ + - $HOME/.android/build-cache +android: + components: + - tools + - tools + - platform-tools + - build-tools-25.0.3 + - android-24 + - android-25 + - extra-google-google_play_services + - extra-google-m2repository + - addon-google_apis-google-25 + - sys-img-armeabi-v7a-android-19 +install: true +before_script: + - echo no | android create avd --force -n test -t android-19 --abi armeabi-v7a + - emulator -avd test -no-audio -no-window & + - android-wait-for-emulator + - adb shell input keyevent 82 & +script: + - ./gradlew :apptentive:test -i +after_script: + - if [ "$TRAVIS_BRANCH" = "develop" ]; then + ./gradlew :apptentive:uploadArchives; + fi +notifications: + slack: + secure: HejMl0EUociwGu+5djx95snbS+m/Yw8DseQKCSqeyWvMQLrAy8bi9oa89mZvXnvjqSVY3kKRZgJncEkQdIe9c7xwgNA9QYLkc7UVbXqga291HMoNnWaIMewD2ervbzM4aBQAHnkDr+GsXgb7+1YdOktIn8dA7jdIuB90ar4So9U= diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b28565f8..6ed158fa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2017-07-19 - 4.0.0 + +#### Improvements + +* Added the ability to log customers in to different private conversations. Customers who are logged in will be able to see a private conversation that is distinct from the converations of other customers using the same app on the same device. When they log out, their data is protected with strong encryption. Logging back in unlocks their data again, and Apptentive resumes providing services to the customer. + # 2017-03-02 - 3.4.1 #### Improvements diff --git a/README.md b/README.md index 07323afc0..f14f06956 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ use your app, to talk to them at the right time, and in the right way. ##### [Release Notes](https://learn.apptentive.com/knowledge-base/android-sdk-release-notes/) -##### Binary releases are hosted for Maven [here](http://search.maven.org/#artifactdetails|com.apptentive|apptentive-android|3.4.1|aar) +##### Binary releases are hosted for Maven [here](http://search.maven.org/#artifactdetails|com.apptentive|apptentive-android|4.0.0|aar) #### Reporting Bugs diff --git a/apptentive/build.gradle b/apptentive/build.gradle index 933d33e8c..80d4293c7 100644 --- a/apptentive/build.gradle +++ b/apptentive/build.gradle @@ -2,10 +2,6 @@ init() apply plugin: 'com.android.library' -repositories { - jcenter() -} - dependencies { compile 'com.android.support:support-v4:24.2.1' compile 'com.android.support:appcompat-v7:24.2.1' @@ -24,14 +20,14 @@ dependencies { } android { - compileSdkVersion 24 - buildToolsVersion '24.0.3' + compileSdkVersion 25 + buildToolsVersion '25.0.3' defaultConfig { minSdkVersion 14 - targetSdkVersion 24 + targetSdkVersion 25 // BUILD_NUMBER is provided by Jenkins. Default to 1 in dev builds. - versionCode System.getenv("BUILD_NUMBER") as Integer ?: 1 + versionCode System.getenv("BUILD_NUMBER") as Integer ?: System.getenv("TRAVIS_BUILD_NUMBER") as Integer ?: 1 versionName project.version consumerProguardFiles 'consumer-proguard-rules.pro' testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" @@ -72,14 +68,14 @@ def getReleaseRepositoryUrl() { } def getSnapshotRepositoryUrl() { - return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL : "" + return hasProperty('SNAPSHOT_REPOSITORY_URL') ? SNAPSHOT_REPOSITORY_URL : System.getenv("SNAPSHOT_REPOSITORY_URL") } def getRepositoryUsername() { if (isReleaseBuild()) { return hasProperty('NEXUS_USERNAME') ? NEXUS_USERNAME : "" } else { - return hasProperty('SNAPSHOT_REPOSITORY_USERNAME') ? SNAPSHOT_REPOSITORY_USERNAME : "" + return hasProperty('SNAPSHOT_REPOSITORY_USERNAME') ? SNAPSHOT_REPOSITORY_USERNAME : System.getenv("SNAPSHOT_REPOSITORY_USERNAME") } } @@ -87,7 +83,7 @@ def getRepositoryPassword() { if (isReleaseBuild()) { return hasProperty('NEXUS_PASSWORD') ? NEXUS_PASSWORD : "" } else { - return hasProperty('SNAPSHOT_REPOSITORY_PASSWORD') ? SNAPSHOT_REPOSITORY_PASSWORD : "" + return hasProperty('SNAPSHOT_REPOSITORY_PASSWORD') ? SNAPSHOT_REPOSITORY_PASSWORD : System.getenv("SNAPSHOT_REPOSITORY_PASSWORD") } } @@ -162,7 +158,7 @@ task androidJavadocs(type: Javadoc) { classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) } -androidJavadocs << { +androidJavadocs.doLast { println "Javadocs written to: " + destinationDir } diff --git a/apptentive/src/androidTest/assets/apptentive-v2 b/apptentive/src/androidTest/assets/apptentive-v2 new file mode 100644 index 000000000..51b0ff1be Binary files /dev/null and b/apptentive/src/androidTest/assets/apptentive-v2 differ diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/conversation/FileMessageStoreTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/conversation/FileMessageStoreTest.java new file mode 100644 index 000000000..f833e83ee --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/conversation/FileMessageStoreTest.java @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.conversation; + +import com.apptentive.android.sdk.ApptentiveInternal; +import com.apptentive.android.sdk.ApptentiveInternalMock; +import com.apptentive.android.sdk.TestCaseBase; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.CompoundMessage; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.util.Iterator; +import java.util.List; +import java.util.UUID; + +import static com.apptentive.android.sdk.model.ApptentiveMessage.State; +import static junit.framework.Assert.assertEquals; + +public class FileMessageStoreTest extends TestCaseBase { + private static final boolean READ = true; + private static final boolean UNREAD = false; + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Before + public void setUp() { + super.setUp(); + ApptentiveInternal.setInstance(new ApptentiveInternalMock()); + } + + @After + public void tearDown() { + super.tearDown(); + ApptentiveInternal.setInstance(null); + } + + @Test + public void testAddingAndLoadingMessages() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + + // reload store and check saved messages + store = new FileMessageStore(file); + addResult(store.getAllMessages()); + + assertResult( + "{'nonce':'1','client_created_at':'10','state':'sending','read':'true'}", + "{'nonce':'2','client_created_at':'20','state':'sent','read':'false'}", + "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + + // reload the store again and add another message + store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("4", State.sent, UNREAD, 40.0)); + addResult(store.getAllMessages()); + + assertResult( + "{'nonce':'1','client_created_at':'10','state':'sending','read':'true'}", + "{'nonce':'2','client_created_at':'20','state':'sent','read':'false'}", + "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}", + "{'nonce':'4','client_created_at':'40','state':'sent','read':'false'}"); + } + + @Test + public void updateMessage() throws Exception { + + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + + // reload the store and change a single message + store = new FileMessageStore(file); + store.updateMessage(createMessage("2", State.saved, READ, 40.0)); + addResult(store.getAllMessages()); + + assertResult( + "{'nonce':'1','client_created_at':'10','state':'sending','read':'true'}", + "{'nonce':'2','client_created_at':'40','state':'saved','read':'true'}", + "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + + + // reload the store and check the stored messages + store = new FileMessageStore(file); + addResult(store.getAllMessages()); + + assertResult( + "{'nonce':'1','client_created_at':'10','state':'sending','read':'true'}", + "{'nonce':'2','client_created_at':'40','state':'saved','read':'true'}", + "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + } + + @Test + public void getLastReceivedMessageId() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.saved, READ, 10.0, "111")); + store.addOrUpdateMessages(createMessage("2", State.saved, UNREAD, 20.0, "222")); + store.addOrUpdateMessages(createMessage("3", State.sending, READ, 30.0, "333")); + store.addOrUpdateMessages(createMessage("4", State.sent, UNREAD, 40.0, "444")); + + assertEquals("222", store.getLastReceivedMessageId()); + + // reload the store and check again + store = new FileMessageStore(file); + assertEquals("222", store.getLastReceivedMessageId()); + } + + @Test + public void getUnreadMessageCount() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + store.addOrUpdateMessages(createMessage("4", State.sending, UNREAD, 40.0)); + + assertEquals(2, store.getUnreadMessageCount()); + + // reload store and check saved messages + store = new FileMessageStore(file); + assertEquals(2, store.getUnreadMessageCount()); + } + + @Test + public void deleteAllMessages() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + store.addOrUpdateMessages(createMessage("4", State.sending, UNREAD, 40.0)); + + // delete all messages + store.deleteAllMessages(); + + // check stored messages + addResult(store.getAllMessages()); + assertResult(); + + // reload the store and check for messages + store = new FileMessageStore(file); + addResult(store.getAllMessages()); + assertResult(); + } + + @Test + public void deleteAllMessagesAfterReload() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + store.addOrUpdateMessages(createMessage("4", State.sending, UNREAD, 40.0)); + + // delete all messages + store.deleteAllMessages(); + + // reload the store and check for messages + store = new FileMessageStore(file); + addResult(store.getAllMessages()); + assertResult(); + } + + @Test + public void deleteMessage() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + store.addOrUpdateMessages(createMessage("4", State.sending, UNREAD, 40.0)); + + store.deleteMessage("2"); + store.deleteMessage("4"); + + addResult(store.getAllMessages()); + + assertResult( + "{'nonce':'1','client_created_at':'10','state':'sending','read':'true'}", + "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + } + + @Test + public void deleteMessageAndReload() throws Exception { + File file = getTempFile(); + + // create a few messages and add them to the store + FileMessageStore store = new FileMessageStore(file); + store.addOrUpdateMessages(createMessage("1", State.sending, READ, 10.0)); + store.addOrUpdateMessages(createMessage("2", State.sent, UNREAD, 20.0)); + store.addOrUpdateMessages(createMessage("3", State.saved, READ, 30.0)); + store.addOrUpdateMessages(createMessage("4", State.sending, UNREAD, 40.0)); + + store.deleteMessage("2"); + store.deleteMessage("4"); + + // reload store + store = new FileMessageStore(file); + addResult(store.getAllMessages()); + + assertResult( + "{'nonce':'1','client_created_at':'10','state':'sending','read':'true'}", + "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + + // delete more + store.deleteMessage("1"); + addResult(store.getAllMessages()); + + assertResult("{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + + // reload store + store = new FileMessageStore(file); + addResult(store.getAllMessages()); + + assertResult("{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); + } + + private ApptentiveMessage createMessage(String nonce, State state, boolean read, double clientCreatedAt) throws JSONException { + return createMessage(nonce, state, read, clientCreatedAt, UUID.randomUUID().toString()); + } + + private ApptentiveMessage createMessage(String nonce, State state, boolean read, double clientCreatedAt, String id) throws JSONException { + JSONObject object = new JSONObject(); + object.put("nonce", nonce); + object.put("client_created_at", clientCreatedAt); + CompoundMessage message = new CompoundMessage(object.toString()); + message.setId(id); + message.setState(state); + message.setRead(read); + message.setNonce(nonce); + return message; + } + + private File getTempFile() throws IOException { + final File file = tempFolder.newFile(); + file.delete(); // this file might exist + return file; + } + + private void addResult(List messages) throws JSONException { + for (ApptentiveMessage message : messages) { + addResult(toString(message)); + } + } + + private String toString(ApptentiveMessage message) throws JSONException { + String result = "{"; + final Iterator keys = message.getJsonObject().keys(); + while (keys.hasNext()) { + String key = keys.next(); + if (key.equals("id")) { // 'id' is randomly generated each time (so don't test it) + continue; + } + if (key.equals("type")) { // it's always 'CompoundMessage' + continue; + } + result += StringUtils.format("'%s':'%s',", key, message.getJsonObject().get(key)); + } + + result += StringUtils.format("'state':'%s',", message.getState().name()); + result += StringUtils.format("'read':'%s'", message.isRead()); + result += "}"; + return result; + } +} \ No newline at end of file diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptorTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptorTest.java new file mode 100644 index 000000000..7cb9d1789 --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptorTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.encryption; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class EncryptorTest { + + private static final int TEST_DATA_SIZE = 8096; + private Encryptor encryptor; + private byte[] testData; + + @Before + public void setupEncryptor() throws Exception { + // Generate a key and setup the crypto + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(256); + SecretKey secretKey = keyGen.generateKey(); + encryptor = new Encryptor(secretKey.getEncoded()); + + // Set up the test data + testData = new byte[TEST_DATA_SIZE]; + new Random().nextBytes(testData); + } + + @Test + public void testRoundTripEncryption() throws Exception { + long start = System.currentTimeMillis(); + byte[] cipherText = encryptor.encrypt(testData); + assertNotNull(cipherText); + byte[] plainText = encryptor.decrypt(cipherText); + long stop = System.currentTimeMillis(); + System.out.println(String.format("Round trip encryption took: %dms", stop - start)); + assertNotNull(plainText); + assertTrue(Arrays.equals(plainText, testData)); + } +} \ No newline at end of file diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/network/HttpRequestManagerTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/network/HttpRequestManagerTest.java new file mode 100644 index 000000000..ff1b239a7 --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/network/HttpRequestManagerTest.java @@ -0,0 +1,327 @@ +package com.apptentive.android.sdk.network; + +import com.apptentive.android.sdk.TestCaseBase; +import com.apptentive.android.sdk.network.MockHttpURLConnection.DefaultResponseHandler; +import com.apptentive.android.sdk.util.threading.MockDispatchQueue; + +import junit.framework.Assert; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class HttpRequestManagerTest extends TestCaseBase { + + private HttpRequestManager requestManager; + private MockDispatchQueue networkQueue; + + @Before + public void setUp() { + super.setUp(); + + networkQueue = new MockDispatchQueue(false); + requestManager = new MockHttpRequestManager(networkQueue); + } + + @After + public void tearDown() { + requestManager.cancelAll(); + super.tearDown(); + } + + @Test + public void testStartRequest() { + startRequest(new MockHttpRequest("1")); + startRequest(new MockHttpRequest("2").setMockResponseCode(204)); + startRequest(new MockHttpRequest("3").setMockResponseCode(500)); + startRequest(new MockHttpRequest("4").setThrowsExceptionOnConnect(true)); + startRequest(new MockHttpRequest("5").setThrowsExceptionOnDisconnect(true)); + dispatchRequests(); + + assertResult( + "finished: 1", + "finished: 2", + "failed: 3 Unexpected response code: 500 (Internal Server Error)", + "failed: 4 Connection error", + "failed: 5 Disconnection error" + ); + } + + @Test + public void testRequestData() { + final String expected = "Some test data with Unicode chars 文字"; + + final AtomicBoolean finished = new AtomicBoolean(false); + + HttpRequest request = new MockHttpRequest("request").setResponseData(expected); + request.addListener(new HttpRequest.Adapter() { + @Override + public void onFinish(HttpRequest request) { + Assert.assertEquals(expected, request.getResponseData()); + finished.set(true); + } + }); + requestManager.startRequest(request); + dispatchRequests(); + + Assert.assertTrue(finished.get()); + } + + @Test + public void testJsonRequestData() throws JSONException { + final JSONObject requestObject = new JSONObject(); + requestObject.put("key1", "value1"); + requestObject.put("key2", "value2"); + requestObject.put("key3", "value3"); + + final JSONObject expected = new JSONObject(); + expected.put("int", 10); + expected.put("string", "value"); + expected.put("boolean", true); + expected.put("float", 3.14f); + + JSONObject inner = new JSONObject(); + inner.put("key", "value"); + expected.put("inner", inner); + + final AtomicBoolean finished = new AtomicBoolean(false); + + HttpJsonRequest request = new MockHttpJsonRequest("request", requestObject).setMockResponseData(expected); + request.addListener(new HttpRequest.Adapter() { + @Override + public void onFinish(HttpJsonRequest request) { + Assert.assertEquals(expected.toString(), request.getResponseObject().toString()); + finished.set(true); + } + + @Override + public void onFail(HttpJsonRequest request, String reason) { + Assert.fail(reason); + } + }); + requestManager.startRequest(request); + dispatchRequests(); + + Assert.assertTrue(finished.get()); + } + + @Test + public void testJsonRequestCorruptedData() throws JSONException { + final JSONObject requestObject = new JSONObject(); + requestObject.put("key1", "value1"); + requestObject.put("key2", "value2"); + requestObject.put("key3", "value3"); + + String invalidJson = "{ key1 : value key2 : value2 }"; + + final AtomicBoolean finished = new AtomicBoolean(false); + + HttpJsonRequest request = new MockHttpJsonRequest("request", requestObject).setMockResponseData(invalidJson); + request.addListener(new HttpRequest.Adapter() { + @Override + public void onFail(HttpJsonRequest request, String reason) { + finished.set(true); + } + }); + requestManager.startRequest(request); + dispatchRequests(); + + Assert.assertTrue(finished.get()); + } + + @Test + public void testListener() { + requestManager.setListener(new HttpRequestManager.Listener() { + @Override + public void onRequestStart(HttpRequestManager manager, HttpRequest request) { + addResult("start: " + request); + } + + @Override + public void onRequestFinish(HttpRequestManager manager, HttpRequest request) { + if (request.isSuccessful()) { + addResult("finish: " + request); + } else if (request.isCancelled()) { + addResult("cancel: " + request); + } else { + addResult("fail: " + request); + } + } + + @Override + public void onRequestsCancel(HttpRequestManager manager) { + addResult("cancel all"); + } + }); + + + // start requests and let them finish + requestManager.startRequest(new MockHttpRequest("1")); + requestManager.startRequest(new MockHttpRequest("2").setMockResponseCode(500)); + requestManager.startRequest(new MockHttpRequest("3").setThrowsExceptionOnConnect(true)); + dispatchRequests(); + + assertResult( + "start: 1", + "start: 2", + "start: 3", + "finish: 1", + "fail: 2", + "fail: 3" + ); + + // start requests and cancel some + requestManager.startRequest(new MockHttpRequest("4")); + requestManager.startRequest(new MockHttpRequest("5")).cancel(); + requestManager.startRequest(new MockHttpRequest("6")); + dispatchRequests(); + + assertResult( + "start: 4", + "start: 5", + "start: 6", + "finish: 4", + "cancel: 5", + "finish: 6" + ); + + // start requests and cancel them all + requestManager.startRequest(new MockHttpRequest("4")); + requestManager.startRequest(new MockHttpRequest("5")); + requestManager.startRequest(new MockHttpRequest("6")); + requestManager.cancelAll(); + dispatchRequests(); + + assertResult( + "start: 4", + "start: 5", + "start: 6", + "cancel all", + "cancel: 4", + "cancel: 5", + "cancel: 6" + ); + } + + @Test + public void testFailedRetry() { + HttpRequestRetryPolicyDefault retryPolicy = new HttpRequestRetryPolicyDefault() { + @Override + public boolean shouldRetryRequest(int responseCode, int retryAttempt) { + return responseCode != -1 && super.shouldRetryRequest(responseCode, retryAttempt); // don't retry on an exception + } + }; + retryPolicy.setMaxRetryCount(2); + retryPolicy.setRetryTimeoutMillis(0); + + startRequest(new MockHttpRequest("1").setMockResponseCode(500).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("2").setMockResponseCode(400).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("3").setMockResponseCode(204).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("4").setThrowsExceptionOnConnect(true).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("5").setThrowsExceptionOnDisconnect(true).setRetryPolicy(retryPolicy)); + dispatchRequests(); + + assertResult( + "failed: 2 Unexpected response code: 400 (Bad Request)", + "finished: 3", + "failed: 4 Connection error", + "failed: 5 Disconnection error", + "retried: 1", + "retried: 1", + "failed: 1 Unexpected response code: 500 (Internal Server Error)" + ); + } + + @Test + public void testSuccessfulRetry() { + HttpRequestRetryPolicyDefault retryPolicy = new HttpRequestRetryPolicyDefault() { + @Override + public boolean shouldRetryRequest(int responseCode, int retryAttempt) { + return responseCode != -1 && super.shouldRetryRequest(responseCode, retryAttempt); // don't retry on an exception + } + }; + retryPolicy.setMaxRetryCount(3); + retryPolicy.setRetryTimeoutMillis(0); + + // fail this request twice and then finish successfully + startRequest(new MockHttpRequest("1").setMockResponseHandler(new DefaultResponseHandler() { + int requestAttempts = 0; + + @Override + public int getResponseCode() { + return requestAttempts++ < 2 ? 500 : 200; + } + }).setRetryPolicy(retryPolicy)); + + startRequest(new MockHttpRequest("2").setMockResponseCode(400).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("3").setMockResponseCode(204).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("4").setThrowsExceptionOnConnect(true).setRetryPolicy(retryPolicy)); + startRequest(new MockHttpRequest("5").setThrowsExceptionOnDisconnect(true).setRetryPolicy(retryPolicy)); + dispatchRequests(); + + assertResult( + "failed: 2 Unexpected response code: 400 (Bad Request)", + "finished: 3", + "failed: 4 Connection error", + "failed: 5 Disconnection error", + "retried: 1", + "retried: 1", + "finished: 1" + ); + } + + //region Helpers + + private void startRequest(HttpRequest request) { + request.addListener(new HttpRequest.Listener() { + @Override + public void onFinish(MockHttpRequest request) { + addResult("finished: " + request); + } + + @Override + public void onCancel(MockHttpRequest request) { + addResult("cancelled: " + request); + } + + @Override + public void onFail(MockHttpRequest request, String reason) { + addResult("failed: " + request + " " + reason); + } + }); + + requestManager.startRequest(request); + } + + private void dispatchRequests() { + networkQueue.dispatchTasks(); + } + + //endregion + + //region Mock HttpRequestManager + + private class MockHttpRequestManager extends HttpRequestManager { + MockHttpRequestManager(MockDispatchQueue networkQueue) { + super(networkQueue); + } + + @Override + void dispatchRequest(HttpRequest request) { + if (request.retrying) { + addResult("retried: " + request); + } + super.dispatchRequest(request); + } + + @Override + synchronized HttpRequest startRequest(HttpRequest request) { + request.setRequestManager(this); + return super.startRequest(request); + } + } +} \ No newline at end of file diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelperTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelperTest.java new file mode 100644 index 000000000..39ae07744 --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelperTest.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.test.InstrumentationRegistry; +import android.support.test.runner.AndroidJUnit4; + +import com.apptentive.android.sdk.model.AppReleasePayload; +import com.apptentive.android.sdk.model.DevicePayload; +import com.apptentive.android.sdk.model.EventPayload; +import com.apptentive.android.sdk.model.Payload; +import com.apptentive.android.sdk.model.PersonPayload; +import com.apptentive.android.sdk.model.SdkPayload; +import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; + +import org.json.JSONException; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class ApptentiveDatabaseHelperTest { + + @After + public void tearDown() throws Exception { + deleteDbFile(InstrumentationRegistry.getContext()); + } + + @Test + public void testMigration() throws Exception { + final Context context = InstrumentationRegistry.getContext(); + replaceDbFile(context, "apptentive-v2"); + + Payload[] expectedPayloads = { + new AppReleasePayload("{\"type\":\"android\",\"version_name\":\"3.4.1\",\"identifier\":\"com.apptentive.dev\",\"version_code\":51,\"target_sdk_version\":\"24\",\"inheriting_styles\":true,\"overriding_styles\":true,\"debug\":true}"), + new SdkPayload("{\"version\":\"3.4.1\",\"platform\":\"Android\"}"), + new EventPayload("{\"nonce\":\"b9a91f27-87b4-4bd9-b9a0-5c605891824a\",\"client_created_at\":1.492737199856E9,\"client_created_at_utc_offset\":-25200,\"label\":\"com.apptentive#app#launch\"}"), + new DevicePayload("{\"device\":\"bullhead\",\"integration_config\":{},\"locale_country_code\":\"US\",\"carrier\":\"\",\"uuid\":\"6c0b74d07c064421\",\"build_type\":\"user\",\"cpu\":\"arm64-v8a\",\"os_build\":\"3687331\",\"manufacturer\":\"LGE\",\"radio_version\":\"M8994F-2.6.36.2.20\",\"os_name\":\"Android\",\"build_id\":\"N4F26T\",\"utc_offset\":\"-28800\",\"bootloader_version\":\"BHZ11h\",\"board\":\"bullhead\",\"os_api_level\":\"25\",\"current_carrier\":\"AT&T\",\"network_type\":\"LTE\",\"locale_raw\":\"en_US\",\"brand\":\"google\",\"os_version\":\"7.1.1\",\"product\":\"bullhead\",\"model\":\"Nexus 5X\",\"locale_language_code\":\"en\",\"custom_data\":{}}"), + new PersonPayload("{\"custom_data\":{}}"), + MessageFactory.fromJson("{\"nonce\":\"a68d606c-083a-4496-a5e0-f07bcdff52a4\",\"client_created_at\":1.492737257565E9,\"client_created_at_utc_offset\":-25200,\"type\":\"CompoundMessage\",\"body\":\"Test message\",\"text_only\":false}") + }; + } + + private static void replaceDbFile(Context context, String filename) throws IOException { + InputStream input = context.getAssets().open(filename); + try { + OutputStream output = new FileOutputStream(getDatabaseFile(context)); + try { + byte[] buffer = new byte[1024]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } finally { + output.close(); + } + } finally { + input.close(); + } + } + + private static void deleteDbFile(Context context) throws IOException { + getDatabaseFile(context).delete(); + } + + private static File getDatabaseFile(Context context) { + return context.getDatabasePath("apptentive"); + } + + class ApptentiveDatabaseMockHelper extends ApptentiveDatabaseHelper { + private final String SQL_QUERY_PAYLOAD_LIST = + "SELECT * FROM " + PayloadEntry.TABLE_NAME + + " ORDER BY " + PayloadEntry.COLUMN_PRIMARY_KEY; + + ApptentiveDatabaseMockHelper(Context context) { + super(context); + } + + List listPayloads(SQLiteDatabase db) throws JSONException { + Cursor cursor = null; + try { + cursor = db.rawQuery(SQL_QUERY_PAYLOAD_LIST, null); + List payloads = new ArrayList<>(cursor.getCount()); + + Payload payload; + while (cursor.moveToNext()) { + // TODO: get data + } + + return payloads; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + } +} \ No newline at end of file diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/EncryptedPayloadSenderTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/EncryptedPayloadSenderTest.java new file mode 100644 index 000000000..1d4fc4707 --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/EncryptedPayloadSenderTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.TestCaseBase; +import com.apptentive.android.sdk.encryption.Encryptor; +import com.apptentive.android.sdk.model.EventPayload; + +import org.json.JSONObject; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +public class EncryptedPayloadSenderTest extends TestCaseBase { + + private static final String AUTH_TOKEN = "auth_token"; + private static final String ENCRYPTION_KEY = "5C5361D08DA7AD6CD70ACEB572D387BB713A312DE8CE6128B8A42F62A7B381DB"; + private static final String EVENT_LABEL = "com.apptentive#app#launch"; + + @Test + public void testEncryptedPayload() throws Exception { + + final EventPayload original = new EventPayload(EVENT_LABEL, "trigger"); + original.setToken(AUTH_TOKEN); + original.setEncryptionKey(ENCRYPTION_KEY); + + byte[] cipherText = original.renderData(); + + Encryptor encryptor = new Encryptor(ENCRYPTION_KEY); + + try { + byte[] plainText = encryptor.decrypt(cipherText); + JSONObject result = new JSONObject(new String(plainText)); + String label = result.getJSONObject("event").getString("label"); + assertEquals(label, EVENT_LABEL); + } catch (Exception e) { + fail(e.getMessage()); + } + } +} \ No newline at end of file diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueueTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueueTest.java index fb405b4e2..51d9ebec7 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueueTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueueTest.java @@ -21,12 +21,14 @@ public class ConcurrentDispatchQueueTest extends TestCaseBase { @Before public void setUp() { + super.setUp(); dispatchQueue = DispatchQueue.createBackgroundQueue("Test Queue", DispatchQueueType.Concurrent); } @After public void tearDown() { dispatchQueue.stop(); + super.tearDown(); } @Test diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/SerialDispatchQueueTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/SerialDispatchQueueTest.java index b8dbd1ebc..b37af3b25 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/SerialDispatchQueueTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/util/threading/SerialDispatchQueueTest.java @@ -22,12 +22,14 @@ public class SerialDispatchQueueTest extends TestCaseBase { @Before public void setUp() { + super.setUp(); dispatchQueue = DispatchQueue.createBackgroundQueue("Test Queue", DispatchQueueType.Serial); } @After public void tearDown() { dispatchQueue.stop(); + super.tearDown(); } @Test @@ -63,6 +65,7 @@ public void testStoppingDispatch() { protected void execute() { dispatchQueue.stop(); sleep(500); + dispatchQueue.stop(); addResult("task-1"); } }); diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/Apptentive.java b/apptentive/src/main/java/com/apptentive/android/sdk/Apptentive.java index 98e065672..814824b6a 100755 --- a/apptentive/src/main/java/com/apptentive/android/sdk/Apptentive.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/Apptentive.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -17,8 +17,9 @@ import android.text.TextUtils; import android.webkit.MimeTypeMap; +import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.model.CommerceExtendedData; -import com.apptentive.android.sdk.model.CustomData; +import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.model.ExtendedData; import com.apptentive.android.sdk.model.LocationExtendedData; import com.apptentive.android.sdk.model.StoredFile; @@ -26,47 +27,53 @@ import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.messagecenter.MessageManager; import com.apptentive.android.sdk.module.messagecenter.UnreadMessagesListener; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; import com.apptentive.android.sdk.module.metric.MetricModule; import com.apptentive.android.sdk.module.rating.IRatingProvider; import com.apptentive.android.sdk.module.survey.OnSurveyFinishedListener; -import com.apptentive.android.sdk.storage.DeviceManager; -import com.apptentive.android.sdk.storage.PersonManager; +import com.apptentive.android.sdk.storage.Person; import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.StringUtils; import com.apptentive.android.sdk.util.Util; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchTask; import org.json.JSONException; import org.json.JSONObject; import java.io.ByteArrayInputStream; import java.io.InputStream; +import java.io.Serializable; import java.util.ArrayList; import java.util.Map; /** - * This class contains the complete API for accessing Apptentive features from within your app. + * This class contains the complete public API for accessing Apptentive features from within your app. */ public class Apptentive { - /** * Must be called from the {@link Application#onCreate()} method in the {@link Application} object defined in your app's manifest. * * @param application The {@link Application} object for this app. */ public static void register(Application application) { - Apptentive.register(application, null); + register(application, null, null); } - public static void register(Application application, String apptentiveApiKey) { - ApptentiveLog.i("Registering Apptentive."); - ApptentiveInternal.createInstance(application, apptentiveApiKey); - ApptentiveInternal.setLifeCycleCallback(); + public static void register(Application application, String apptentiveKey, String apptentiveSignature) { + register(application, apptentiveKey, apptentiveSignature, null); } - // **************************************************************************************** - // GLOBAL DATA METHODS - // **************************************************************************************** + private static void register(Application application, String apptentiveKey, String apptentiveSignature, String serverUrl) { + try { + ApptentiveLog.i("Registering Apptentive."); + ApptentiveInternal.createInstance(application, apptentiveKey, apptentiveSignature, serverUrl); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while registering Apptentive"); + } + } + + //region Global Data Methods /** * Sets the user's email address. This email address will be sent to the Apptentive server to allow out of app @@ -78,8 +85,16 @@ public static void register(Application application, String apptentiveApiKey) { * @param email The user's email address. */ public static void setPersonEmail(String email) { - if (ApptentiveInternal.isApptentiveRegistered()) { - PersonManager.storePersonEmail(email); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().setEmail(email); + conversation.schedulePersonUpdate(); + } + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while setting person email"); } } @@ -90,8 +105,15 @@ public static void setPersonEmail(String email) { * @return The person's email if set, else null. */ public static String getPersonEmail() { - if (ApptentiveInternal.isApptentiveRegistered()) { - return PersonManager.loadPersonEmail(); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + return conversation.getPerson().getEmail(); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while getting person email"); } return null; } @@ -106,8 +128,16 @@ public static String getPersonEmail() { * @param name The user's name. */ public static void setPersonName(String name) { - if (ApptentiveInternal.isApptentiveRegistered()) { - PersonManager.storePersonName(name); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().setName(name); + conversation.schedulePersonUpdate(); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while setting person name"); } } @@ -118,34 +148,17 @@ public static void setPersonName(String name) { * @return The person's name if set, else null. */ public static String getPersonName() { - if (ApptentiveInternal.isApptentiveRegistered()) { - return PersonManager.loadPersonName(); - } - return null; - } - - - /** - *

Allows you to pass arbitrary string data to the server along with this device's info. This method will replace all - * custom device data that you have set for this app. Calls to this method are idempotent.

- *

To add a single piece of custom device data, use {@link #addCustomDeviceData}

- *

To remove a single piece of custom device data, use {@link #removeCustomDeviceData}

- * - * @param customDeviceData A Map of key/value pairs to send to the server. - * @deprecated - */ - public static void setCustomDeviceData(Map customDeviceData) { - if (ApptentiveInternal.isApptentiveRegistered()) { - try { - CustomData customData = new CustomData(); - for (String key : customDeviceData.keySet()) { - customData.put(key, customDeviceData.get(key)); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + return conversation.getPerson().getName(); } - DeviceManager.storeCustomDeviceData(customData); - } catch (JSONException e) { - ApptentiveLog.w("Unable to set custom device data.", e); } + } catch (Exception e) { + ApptentiveLog.e("Exception while getting person name"); } + return null; } /** @@ -157,11 +170,19 @@ public static void setCustomDeviceData(Map customDeviceData) { * @param value A String value. */ public static void addCustomDeviceData(String key, String value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - if (value != null) { - value = value.trim(); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + if (value != null) { + value = value.trim(); + } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getDevice().getCustomData().put(key, value); + conversation.scheduleDeviceUpdate(); + } } - ApptentiveInternal.getInstance().addCustomDeviceData(key, value); + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom device data"); } } @@ -174,8 +195,15 @@ public static void addCustomDeviceData(String key, String value) { * @param value A Number value. */ public static void addCustomDeviceData(String key, Number value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomDeviceData(key, value); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getDevice().getCustomData().put(key, value); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom device data"); } } @@ -188,20 +216,41 @@ public static void addCustomDeviceData(String key, Number value) { * @param value A Boolean value. */ public static void addCustomDeviceData(String key, Boolean value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomDeviceData(key, value); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getDevice().getCustomData().put(key, value); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom device data"); } } private static void addCustomDeviceData(String key, Version version) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomDeviceData(key, version); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getDevice().getCustomData().put(key, version); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom device data"); } } private static void addCustomDeviceData(String key, DateTime dateTime) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomDeviceData(key, dateTime); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getDevice().getCustomData().put(key, dateTime); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom device data"); } } @@ -211,36 +260,16 @@ private static void addCustomDeviceData(String key, DateTime dateTime) { * @param key The key to remove. */ public static void removeCustomDeviceData(String key) { - if (ApptentiveInternal.isApptentiveRegistered()) { - CustomData customData = DeviceManager.loadCustomDeviceData(); - if (customData != null) { - customData.remove(key); - DeviceManager.storeCustomDeviceData(customData); - } - } - } - - /** - *

Allows you to pass arbitrary string data to the server along with this person's info. This method will replace all - * custom person data that you have set for this app. Calls to this method are idempotent.

- *

To add a single piece of custom person data, use {@link #addCustomPersonData}

- *

To remove a single piece of custom person data, use {@link #removeCustomPersonData}

- * - * @param customPersonData A Map of key/value pairs to send to the server. - * @deprecated - */ - public static void setCustomPersonData(Map customPersonData) { - ApptentiveLog.w("Setting custom person data: %s", customPersonData.toString()); - if (ApptentiveInternal.isApptentiveRegistered()) { - try { - CustomData customData = new CustomData(); - for (String key : customPersonData.keySet()) { - customData.put(key, customPersonData.get(key)); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getDevice().getCustomData().remove(key); + conversation.scheduleDeviceUpdate(); } - PersonManager.storeCustomPersonData(customData); - } catch (JSONException e) { - ApptentiveLog.e("Unable to set custom person data.", e); } + } catch (Exception e) { + ApptentiveLog.e("Exception while removing custom device data"); } } @@ -253,11 +282,19 @@ public static void setCustomPersonData(Map customPersonData) { * @param value A String value. */ public static void addCustomPersonData(String key, String value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - if (value != null) { - value = value.trim(); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + if (value != null) { + value = value.trim(); + } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().getCustomData().put(key, value); + conversation.schedulePersonUpdate(); + } } - ApptentiveInternal.getInstance().addCustomPersonData(key, value); + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom person data"); } } @@ -270,8 +307,15 @@ public static void addCustomPersonData(String key, String value) { * @param value A Number value. */ public static void addCustomPersonData(String key, Number value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomPersonData(key, value); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().getCustomData().put(key, value); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom person data"); } } @@ -284,20 +328,44 @@ public static void addCustomPersonData(String key, Number value) { * @param value A Boolean value. */ public static void addCustomPersonData(String key, Boolean value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomPersonData(key, value); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().getCustomData().put(key, value); + conversation.schedulePersonUpdate(); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom person data"); } } private static void addCustomPersonData(String key, Version version) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomPersonData(key, version); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().getCustomData().put(key, version); + conversation.schedulePersonUpdate(); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom person data"); } } private static void addCustomPersonData(String key, DateTime dateTime) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().addCustomPersonData(key, dateTime); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().getCustomData().remove(key); + conversation.schedulePersonUpdate(); + } + } + } catch (Exception e) { + ApptentiveLog.e("Exception while adding custom person data"); } } @@ -307,54 +375,27 @@ private static void addCustomPersonData(String key, DateTime dateTime) { * @param key The key to remove. */ public static void removeCustomPersonData(String key) { - if (ApptentiveInternal.isApptentiveRegistered()) { - CustomData customData = PersonManager.loadCustomPersonData(); - if (customData != null) { - customData.remove(key); - PersonManager.storeCustomPersonData(customData); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getPerson().getCustomData().remove(key); + conversation.schedulePersonUpdate(); + } } + } catch (Exception e) { + ApptentiveLog.e("Exception while removing custom person data"); } } + //endregion - // **************************************************************************************** - // THIRD PARTY INTEGRATIONS - // **************************************************************************************** + //region Third Party Integrations - private static final String INTEGRATION_APPTENTIVE_PUSH = "apptentive_push"; - private static final String INTEGRATION_PARSE = "parse"; - private static final String INTEGRATION_URBAN_AIRSHIP = "urban_airship"; - private static final String INTEGRATION_AWS_SNS = "aws_sns"; - - private static final String INTEGRATION_PUSH_TOKEN = "token"; - - private static void addIntegration(String integration, Map config) { - if (integration == null || config == null) { - return; - } - if (!ApptentiveInternal.isApptentiveRegistered()) { - return; - } - - CustomData integrationConfig = DeviceManager.loadIntegrationConfig(); - try { - JSONObject configJson = null; - if (!integrationConfig.isNull(integration)) { - configJson = integrationConfig.getJSONObject(integration); - } else { - configJson = new JSONObject(); - integrationConfig.put(integration, configJson); - } - for (String key : config.keySet()) { - configJson.put(key, config.get(key)); - } - ApptentiveLog.d("Adding integration config: %s", config.toString()); - DeviceManager.storeIntegrationConfig(integrationConfig); - ApptentiveInternal.getInstance().syncDevice(); - } catch (JSONException e) { - ApptentiveLog.e("Error adding integration: %s, %s", e, integration, config.toString()); - } - } + /** + * For internal use only. + */ + public static final String INTEGRATION_PUSH_TOKEN = "token"; /** * Call {@link #setPushNotificationIntegration(int, String)} with this value to allow Apptentive to send pushes @@ -412,53 +453,42 @@ private static void addIntegration(String integration, Map confi *
The GCM Registration ID, which you can access like this.
* */ - public static void setPushNotificationIntegration(int pushProvider, String token) { + public static void setPushNotificationIntegration(final int pushProvider, final String token) { try { + // we only access the active conversation on the main thread to avoid concurrency issues + if (!DispatchQueue.isMainQueue()) { + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + setPushNotificationIntegration(pushProvider, token); + } + }); + return; + } + if (!ApptentiveInternal.isApptentiveRegistered()) { + ApptentiveLog.w("Unable to set push notification integration: Apptentive instance is not initialized"); return; } - CustomData integrationConfig = getIntegrationConfigurationWithoutPushProviders(); - JSONObject pushObject = new JSONObject(); - pushObject.put(INTEGRATION_PUSH_TOKEN, token); - switch (pushProvider) { - case PUSH_PROVIDER_APPTENTIVE: - integrationConfig.put(INTEGRATION_APPTENTIVE_PUSH, pushObject); - break; - case PUSH_PROVIDER_PARSE: - integrationConfig.put(INTEGRATION_PARSE, pushObject); - break; - case PUSH_PROVIDER_URBAN_AIRSHIP: - integrationConfig.put(INTEGRATION_URBAN_AIRSHIP, pushObject); - break; - case PUSH_PROVIDER_AMAZON_AWS_SNS: - integrationConfig.put(INTEGRATION_AWS_SNS, pushObject); - break; - default: - ApptentiveLog.e("Invalid pushProvider: %d", pushProvider); - return; - } - DeviceManager.storeIntegrationConfig(integrationConfig); - ApptentiveInternal.getInstance().syncDevice(); - } catch (JSONException e) { - ApptentiveLog.e("Error setting push integration.", e); - return; + // Store the push stuff globally + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + prefs.edit().putInt(Constants.PREF_KEY_PUSH_PROVIDER, pushProvider) + .putString(Constants.PREF_KEY_PUSH_TOKEN, token) + .apply(); + + // Also set it on the active Conversation, if there is one. + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.setPushIntegration(pushProvider, token); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while setting push notification integration"); } } - private static CustomData getIntegrationConfigurationWithoutPushProviders() { - CustomData integrationConfig = DeviceManager.loadIntegrationConfig(); - if (integrationConfig != null) { - integrationConfig.remove(INTEGRATION_APPTENTIVE_PUSH); - integrationConfig.remove(INTEGRATION_PARSE); - integrationConfig.remove(INTEGRATION_URBAN_AIRSHIP); - integrationConfig.remove(INTEGRATION_AWS_SNS); - } - return integrationConfig; - } + //endregion - // **************************************************************************************** - // PUSH NOTIFICATIONS - // **************************************************************************************** + //region Push Notifications /** * Determines whether this Intent is a push notification sent from Apptentive. @@ -467,10 +497,15 @@ private static CustomData getIntegrationConfigurationWithoutPushProviders() { * @return True if the Intent came from, and should be handled by Apptentive. */ public static boolean isApptentivePushNotification(Intent intent) { - if (!ApptentiveInternal.checkRegistered()) { - return false; + try { + if (!ApptentiveInternal.checkRegistered()) { + return false; + } + return ApptentiveInternal.getApptentivePushNotificationData(intent) != null; + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while checking for Apptentive push notification intent"); } - return ApptentiveInternal.getApptentivePushNotificationData(intent) != null; + return false; } /** @@ -481,10 +516,15 @@ public static boolean isApptentivePushNotification(Intent intent) { * @return True if the push came from, and should be handled by Apptentive. */ public static boolean isApptentivePushNotification(Bundle bundle) { - if (!ApptentiveInternal.checkRegistered()) { - return false; + try { + if (!ApptentiveInternal.checkRegistered()) { + return false; + } + return ApptentiveInternal.getApptentivePushNotificationData(bundle) != null; + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while checking for Apptentive push notification bundle"); } - return ApptentiveInternal.getApptentivePushNotificationData(bundle) != null; + return false; } /** @@ -494,18 +534,22 @@ public static boolean isApptentivePushNotification(Bundle bundle) { * @return True if the push came from, and should be handled by Apptentive. */ public static boolean isApptentivePushNotification(Map data) { - if (!ApptentiveInternal.checkRegistered()) { - return false; + try { + if (!ApptentiveInternal.checkRegistered()) { + return false; + } + return ApptentiveInternal.getApptentivePushNotificationData(data) != null; + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while checking for Apptentive push notification data"); } - return ApptentiveInternal.getApptentivePushNotificationData(data) != null; + return false; } /** *

Use this method in your push receiver to build a pending Intent when an Apptentive push * notification is received. Pass the generated PendingIntent to * {@link android.support.v4.app.NotificationCompat.Builder#setContentIntent} to allow Apptentive - * to display Interactions such as Message Center. This method replaces the deprecated - * {@link #setPendingPushNotification(Intent)}. Calling this method for a push {@link Intent} that did + * to display Interactions such as Message Center. Calling this method for a push {@link Intent} that did * not come from Apptentive will return a null object. If you receive a null object, your app will * need to handle this notification itself.

*

This is the method you will likely need if you integrated using:

@@ -520,19 +564,23 @@ public static boolean isApptentivePushNotification(Map data) { * @return a valid {@link PendingIntent} to launch an Apptentive Interaction if the push data came from Apptentive, or null. */ public static PendingIntent buildPendingIntentFromPushNotification(@NonNull Intent intent) { - if (!ApptentiveInternal.checkRegistered()) { - return null; + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; + } + String apptentivePushData = ApptentiveInternal.getApptentivePushNotificationData(intent); + return ApptentiveInternal.generatePendingIntentFromApptentivePushData(apptentivePushData); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while building pending intent from push notification"); } - String apptentivePushData = ApptentiveInternal.getApptentivePushNotificationData(intent); - return ApptentiveInternal.generatePendingIntentFromApptentivePushData(apptentivePushData); + return null; } /** *

Use this method in your push receiver to build a pending Intent when an Apptentive push * notification is received. Pass the generated PendingIntent to * {@link android.support.v4.app.NotificationCompat.Builder#setContentIntent} to allow Apptentive - * to display Interactions such as Message Center. This method replaces the deprecated - * {@link #setPendingPushNotification(Bundle)}. Calling this method for a push {@link Bundle} that + * to display Interactions such as Message Center. Calling this method for a push {@link Bundle} that * did not come from Apptentive will return a null object. If you receive a null object, your app * will need to handle this notification itself.

*

This is the method you will likely need if you integrated using:

@@ -545,19 +593,23 @@ public static PendingIntent buildPendingIntentFromPushNotification(@NonNull Inte * @return a valid {@link PendingIntent} to launch an Apptentive Interaction if the push data came from Apptentive, or null. */ public static PendingIntent buildPendingIntentFromPushNotification(@NonNull Bundle bundle) { - if (!ApptentiveInternal.checkRegistered()) { - return null; + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; + } + String apptentivePushData = ApptentiveInternal.getApptentivePushNotificationData(bundle); + return ApptentiveInternal.generatePendingIntentFromApptentivePushData(apptentivePushData); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while building pending intent form a push notification"); } - String apptentivePushData = ApptentiveInternal.getApptentivePushNotificationData(bundle); - return ApptentiveInternal.generatePendingIntentFromApptentivePushData(apptentivePushData); + return null; } /** *

Use this method in your push receiver to build a pending Intent when an Apptentive push * notification is received. Pass the generated PendingIntent to * {@link android.support.v4.app.NotificationCompat.Builder#setContentIntent} to allow Apptentive - * to display Interactions such as Message Center. This method replaces the deprecated - * {@link #setPendingPushNotification(Bundle)}. Calling this method for a push {@link Bundle} that + * to display Interactions such as Message Center. Calling this method for a push {@link Bundle} that * did not come from Apptentive will return a null object. If you receive a null object, your app * will need to handle this notification itself.

*

This is the method you will likely need if you integrated using:

@@ -571,11 +623,16 @@ public static PendingIntent buildPendingIntentFromPushNotification(@NonNull Bund * @return a valid {@link PendingIntent} to launch an Apptentive Interaction if the push data came from Apptentive, or null. */ public static PendingIntent buildPendingIntentFromPushNotification(@NonNull Map data) { - if (!ApptentiveInternal.checkRegistered()) { - return null; + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; + } + String apptentivePushData = ApptentiveInternal.getApptentivePushNotificationData(data); + return ApptentiveInternal.generatePendingIntentFromApptentivePushData(apptentivePushData); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while building pending intent form a push notification"); } - String apptentivePushData = ApptentiveInternal.getApptentivePushNotificationData(data); - return ApptentiveInternal.generatePendingIntentFromApptentivePushData(apptentivePushData); + return null; } /** @@ -623,31 +680,35 @@ public static String getBodyFromApptentivePush(Intent intent) { * @return a String value, or null. */ public static String getTitleFromApptentivePush(Bundle bundle) { - if (!ApptentiveInternal.checkRegistered()) { - return null; - } - if (bundle == null) { - return null; - } - if (bundle.containsKey(ApptentiveInternal.TITLE_DEFAULT)) { - return bundle.getString(ApptentiveInternal.TITLE_DEFAULT); - } - if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE)) { - String parseDataString = bundle.getString(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE); - if (parseDataString != null) { - try { - JSONObject parseJson = new JSONObject(parseDataString); - return parseJson.optString(ApptentiveInternal.TITLE_DEFAULT, null); - } catch (JSONException e) { - return null; - } + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; } - } else if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_UA)) { - Bundle uaPushBundle = bundle.getBundle(ApptentiveInternal.PUSH_EXTRA_KEY_UA); - if (uaPushBundle == null) { + if (bundle == null) { return null; } - return uaPushBundle.getString(ApptentiveInternal.TITLE_DEFAULT); + if (bundle.containsKey(ApptentiveInternal.TITLE_DEFAULT)) { + return bundle.getString(ApptentiveInternal.TITLE_DEFAULT); + } + if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE)) { + String parseDataString = bundle.getString(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE); + if (parseDataString != null) { + try { + JSONObject parseJson = new JSONObject(parseDataString); + return parseJson.optString(ApptentiveInternal.TITLE_DEFAULT, null); + } catch (JSONException e) { + return null; + } + } + } else if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_UA)) { + Bundle uaPushBundle = bundle.getBundle(ApptentiveInternal.PUSH_EXTRA_KEY_UA); + if (uaPushBundle == null) { + return null; + } + return uaPushBundle.getString(ApptentiveInternal.TITLE_DEFAULT); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while getting title from Apptentive push"); } return null; } @@ -661,33 +722,37 @@ public static String getTitleFromApptentivePush(Bundle bundle) { * @return a String value, or null. */ public static String getBodyFromApptentivePush(Bundle bundle) { - if (!ApptentiveInternal.checkRegistered()) { - return null; - } - if (bundle == null) { - return null; - } - if (bundle.containsKey(ApptentiveInternal.BODY_DEFAULT)) { - return bundle.getString(ApptentiveInternal.BODY_DEFAULT); - } - if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE)) { - String parseDataString = bundle.getString(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE); - if (parseDataString != null) { - try { - JSONObject parseJson = new JSONObject(parseDataString); - return parseJson.optString(ApptentiveInternal.BODY_PARSE, null); - } catch (JSONException e) { - return null; - } + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; } - } else if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_UA)) { - Bundle uaPushBundle = bundle.getBundle(ApptentiveInternal.PUSH_EXTRA_KEY_UA); - if (uaPushBundle == null) { + if (bundle == null) { return null; } - return uaPushBundle.getString(ApptentiveInternal.BODY_UA); - } else if (bundle.containsKey(ApptentiveInternal.BODY_UA)) { - return bundle.getString(ApptentiveInternal.BODY_UA); + if (bundle.containsKey(ApptentiveInternal.BODY_DEFAULT)) { + return bundle.getString(ApptentiveInternal.BODY_DEFAULT); + } + if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE)) { + String parseDataString = bundle.getString(ApptentiveInternal.PUSH_EXTRA_KEY_PARSE); + if (parseDataString != null) { + try { + JSONObject parseJson = new JSONObject(parseDataString); + return parseJson.optString(ApptentiveInternal.BODY_PARSE, null); + } catch (JSONException e) { + return null; + } + } + } else if (bundle.containsKey(ApptentiveInternal.PUSH_EXTRA_KEY_UA)) { + Bundle uaPushBundle = bundle.getBundle(ApptentiveInternal.PUSH_EXTRA_KEY_UA); + if (uaPushBundle == null) { + return null; + } + return uaPushBundle.getString(ApptentiveInternal.BODY_UA); + } else if (bundle.containsKey(ApptentiveInternal.BODY_UA)) { + return bundle.getString(ApptentiveInternal.BODY_UA); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while getting body from Apptentive push"); } return null; } @@ -702,13 +767,18 @@ public static String getBodyFromApptentivePush(Bundle bundle) { * @return a String value, or null. */ public static String getTitleFromApptentivePush(Map data) { - if (!ApptentiveInternal.checkRegistered()) { - return null; - } - if (data == null) { - return null; + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; + } + if (data == null) { + return null; + } + return data.get(ApptentiveInternal.TITLE_DEFAULT); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while getting title from Apptentive push"); } - return data.get(ApptentiveInternal.TITLE_DEFAULT); + return null; } /** @@ -721,110 +791,23 @@ public static String getTitleFromApptentivePush(Map data) { * @return a String value, or null. */ public static String getBodyFromApptentivePush(Map data) { - if (!ApptentiveInternal.checkRegistered()) { - return null; - } - if (data == null) { - return null; - } - return data.get(ApptentiveInternal.BODY_DEFAULT); - } - - /** - *

Saves Apptentive specific data from a push notification Intent. In your BroadcastReceiver, if the push notification - * came from Apptentive, it will have data that needs to be saved before you launch your Activity. You must call this - * method every time you get a push opened Intent, and before you launch your Activity. If the push - * notification did not come from Apptentive, this method has no effect.

- *

Use this method when using Parse and Amazon SNS as push providers.

- * - * @param intent The Intent that you received when the user opened a push notification. - * @return true if the push data came from Apptentive. - * @deprecated - */ - @Deprecated - public static boolean setPendingPushNotification(Intent intent) { - if (!ApptentiveInternal.checkRegistered()) { - return false; - } - String apptentive = ApptentiveInternal.getApptentivePushNotificationData(intent); - if (apptentive != null) { - return ApptentiveInternal.getInstance().setPendingPushNotification(apptentive); - } - return false; - } - - /** - * Saves off the data contained in a push notification sent to this device from Apptentive. Use - * this method when a push notification is opened, and you only have access to a push data - * Bundle containing an "apptentive" key. This will generally be used with direct Apptentive Push - * notifications, or when using Urban Airship as a push provider. Calling this method for a push - * that did not come from Apptentive has no effect. - * - * @param data A Bundle containing the GCM data object from the push notification. - * @return true if the push data came from Apptentive. - * @deprecated - */ - @Deprecated - public static boolean setPendingPushNotification(Bundle data) { - if (!ApptentiveInternal.checkRegistered()) { - return false; - } - String apptentive = ApptentiveInternal.getApptentivePushNotificationData(data); - if (apptentive != null) { - return ApptentiveInternal.getInstance().setPendingPushNotification(apptentive); - } - return false; - } - - /** - * Launches Apptentive features based on a push notification Intent. Before you call this, you - * must call {@link #setPendingPushNotification(Intent)} or - * {@link #setPendingPushNotification(Bundle)} in your Broadcast receiver when - * a push notification is opened by the user. This method must be called from the Activity that - * you launched from the BroadcastReceiver. This method will only handle Apptentive originated - * push notifications, so you can and should call it any time your push notification launches an - * Activity. - * - * @param context The context from which this method is called. - * @return True if a call to this method resulted in Apptentive displaying a View. - * @deprecated - */ - @Deprecated - public static boolean handleOpenedPushNotification(Context context) { - if (!ApptentiveInternal.checkRegistered()) { - return false; - } - - SharedPreferences prefs = context.getSharedPreferences(Constants.PREF_NAME, Context.MODE_PRIVATE); - String pushData = prefs.getString(Constants.PREF_KEY_PENDING_PUSH_NOTIFICATION, null); - prefs.edit().remove(Constants.PREF_KEY_PENDING_PUSH_NOTIFICATION).apply(); // Remove our data so this won't run twice. - if (pushData != null) { - ApptentiveLog.i("Handling opened Apptentive push notification."); - try { - JSONObject pushJson = new JSONObject(pushData); - ApptentiveInternal.PushAction action = ApptentiveInternal.PushAction.unknown; - if (pushJson.has(ApptentiveInternal.PUSH_ACTION)) { - action = ApptentiveInternal.PushAction.parse(pushJson.getString(ApptentiveInternal.PUSH_ACTION)); - } - switch (action) { - case pmc: - Apptentive.showMessageCenter(context); - return true; - default: - ApptentiveLog.v("Unknown Apptentive push notification action: \"%s\"", action.name()); - } - } catch (JSONException e) { - ApptentiveLog.w("Error parsing JSON from push notification.", e); - MetricModule.sendError(e, "Parsing Push notification", pushData); + try { + if (!ApptentiveInternal.checkRegistered()) { + return null; } + if (data == null) { + return null; + } + return data.get(ApptentiveInternal.BODY_DEFAULT); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while getting body from Apptentive push"); } - return false; + return null; } + //endregion - // **************************************************************************************** - // RATINGS - // **************************************************************************************** + //region Rating /** * Use this to choose where to send the user when they are prompted to rate the app. This should be the same place @@ -834,8 +817,12 @@ public static boolean handleOpenedPushNotification(Context context) { */ public static void setRatingProvider(IRatingProvider ratingProvider) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().setRatingProvider(ratingProvider); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + ApptentiveInternal.getInstance().setRatingProvider(ratingProvider); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while setting rating provider"); } } @@ -847,15 +834,18 @@ public static void setRatingProvider(IRatingProvider ratingProvider) { * @param value A String */ public static void putRatingProviderArg(String key, String value) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().putRatingProviderArg(key, value); + try { + if (ApptentiveInternal.isApptentiveRegistered()) { + ApptentiveInternal.getInstance().putRatingProviderArg(key, value); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while putting rating provider arg"); } } - // **************************************************************************************** - // MESSAGE CENTER - // **************************************************************************************** + //endregion + //region Message Center /** * Opens the Apptentive Message Center UI Activity @@ -882,11 +872,13 @@ public static boolean showMessageCenter(Context context) { */ public static boolean showMessageCenter(Context context, Map customData) { try { - if (ApptentiveInternal.isApptentiveRegistered()) { - return ApptentiveInternal.getInstance().showMessageCenterInternal(context, customData); + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.v(ApptentiveLogTag.MESSAGES, "Unable to show message center: no active conversation."); + return false; } + return ApptentiveInternal.getInstance().showMessageCenterInternal(context, customData); } catch (Exception e) { - ApptentiveLog.w("Error starting Apptentive Activity.", e); + ApptentiveLog.w(e, "Error in Apptentive.showMessageCenter()"); MetricModule.sendError(e, null, null); } return false; @@ -899,8 +891,15 @@ public static boolean showMessageCenter(Context context, Map cus * @return true if a call to {@link #showMessageCenter(Context)} will display Message Center, else false. */ public static boolean canShowMessageCenter() { - if (ApptentiveInternal.isApptentiveRegistered()) { - return ApptentiveInternal.getInstance().canShowMessageCenterInternal(); + try { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.v(ApptentiveLogTag.MESSAGES, "Unable to show message center: no active conversation."); + return false; + } + return ApptentiveInternal.getInstance().canShowMessageCenterInternal(ApptentiveInternal.getInstance().getConversation()); + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.canShowMessageCenter()"); + MetricModule.sendError(e, null, null); } return false; } @@ -917,8 +916,15 @@ public static boolean canShowMessageCenter() { */ @Deprecated public static void setUnreadMessagesListener(UnreadMessagesListener listener) { - if (ApptentiveInternal.isApptentiveRegistered()) { + try { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.v(ApptentiveLogTag.MESSAGES, "Unable to set unread messages listener: no active conversation."); + return; + } ApptentiveInternal.getInstance().getMessageManager().setHostUnreadMessagesListener(listener); + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.setUnreadMessagesListener()"); + MetricModule.sendError(e, null, null); } } @@ -930,8 +936,18 @@ public static void setUnreadMessagesListener(UnreadMessagesListener listener) { * allows us to keep a weak reference to avoid memory leaks. */ public static void addUnreadMessagesListener(UnreadMessagesListener listener) { - if (ApptentiveInternal.isApptentiveRegistered()) { - ApptentiveInternal.getInstance().getMessageManager().addHostUnreadMessagesListener(listener); + try { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.v(ApptentiveLogTag.MESSAGES, "Unable to add unread messages listener: no active conversation."); + return; + } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation != null) { + conversation.getMessageManager().addHostUnreadMessagesListener(listener); + } + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.addUnreadMessagesListener()"); + MetricModule.sendError(e, null, null); } } @@ -942,10 +958,14 @@ public static void addUnreadMessagesListener(UnreadMessagesListener listener) { */ public static int getUnreadMessageCount() { try { - if (ApptentiveInternal.isApptentiveRegistered()) { - return ApptentiveInternal.getInstance().getMessageManager().getUnreadMessageCount(); + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.v(ApptentiveLogTag.MESSAGES, "Unable to get unread message count: no active conversation."); + return 0; } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + return conversation.getMessageManager().getUnreadMessageCount(); } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.getUnreadMessageCount()"); MetricModule.sendError(e, null, null); } return 0; @@ -958,22 +978,25 @@ public static int getUnreadMessageCount() { * @param text The message you wish to send. */ public static void sendAttachmentText(String text) { - if (ApptentiveInternal.isApptentiveRegistered()) { - try { - CompoundMessage message = new CompoundMessage(); - message.setBody(text); - message.setRead(true); - message.setHidden(true); - message.setSenderId(ApptentiveInternal.getInstance().getPersonId()); - message.setAssociatedFiles(null); - MessageManager mgr = ApptentiveInternal.getInstance().getMessageManager(); - if (mgr != null) { - mgr.sendMessage(message); - } - } catch (Exception e) { - ApptentiveLog.w("Error sending attachment text.", e); - MetricModule.sendError(e, null, null); + try { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.w(ApptentiveLogTag.MESSAGES, "Can't send attachment: No active Conversation."); + return; } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + CompoundMessage message = new CompoundMessage(); + message.setBody(text); + message.setRead(true); + message.setHidden(true); + message.setSenderId(conversation.getPerson().getId()); + message.setAssociatedFiles(null); + MessageManager mgr = conversation.getMessageManager(); + if (mgr != null) { + mgr.sendMessage(message); + } + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.sendAttachmentText(String)"); + MetricModule.sendError(e, null, null); } } @@ -986,16 +1009,20 @@ public static void sendAttachmentText(String text) { */ public static void sendAttachmentFile(String uri) { try { - if (TextUtils.isEmpty(uri) || !ApptentiveInternal.isApptentiveRegistered()) { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.w(ApptentiveLogTag.MESSAGES, "Can't send attachment: No active Conversation."); return; } - + if (TextUtils.isEmpty(uri)) { + return; + } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); CompoundMessage message = new CompoundMessage(); // No body, just attachment message.setBody(null); message.setRead(true); message.setHidden(true); - message.setSenderId(ApptentiveInternal.getInstance().getPersonId()); + message.setSenderId(conversation.getPerson().getId()); ArrayList attachmentStoredFiles = new ArrayList(); /* Make a local copy in the cache dir. By default the file name is "apptentive-api-file + nonce" @@ -1031,9 +1058,8 @@ public static void sendAttachmentFile(String uri) { if (mgr != null) { mgr.sendMessage(message); } - } catch (Exception e) { - ApptentiveLog.w("Error sending attachment file.", e); + ApptentiveLog.w(e, "Error in Apptentive.sendAttachmentFile(String)"); MetricModule.sendError(e, null, null); } } @@ -1047,7 +1073,11 @@ public static void sendAttachmentFile(String uri) { * @param mimeType The mime type of the file. */ public static void sendAttachmentFile(byte[] content, String mimeType) { - if (ApptentiveInternal.isApptentiveRegistered()) { + try { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.i(ApptentiveLogTag.MESSAGES, "Can't send attachment: No active Conversation."); + return; + } ByteArrayInputStream is = null; try { is = new ByteArrayInputStream(content); @@ -1055,6 +1085,9 @@ public static void sendAttachmentFile(byte[] content, String mimeType) { } finally { Util.ensureClosed(is); } + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.sendAttachmentFile(byte[], String)"); + MetricModule.sendError(e, null, null); } } @@ -1068,16 +1101,20 @@ public static void sendAttachmentFile(byte[] content, String mimeType) { */ public static void sendAttachmentFile(InputStream is, String mimeType) { try { - if (is == null || !ApptentiveInternal.isApptentiveRegistered()) { + if (!ApptentiveInternal.isConversationActive()) { + ApptentiveLog.w(ApptentiveLogTag.MESSAGES, "Can't send attachment: No active Conversation."); return; } - + if (is == null) { + return; + } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); CompoundMessage message = new CompoundMessage(); // No body, just attachment message.setBody(null); message.setRead(true); message.setHidden(true); - message.setSenderId(ApptentiveInternal.getInstance().getPersonId()); + message.setSenderId(conversation.getPerson().getId()); ArrayList attachmentStoredFiles = new ArrayList(); String localFilePath = Util.generateCacheFilePathFromNonceOrPrefix(ApptentiveInternal.getInstance().getApplicationContext(), message.getNonce(), null); @@ -1097,11 +1134,13 @@ public static void sendAttachmentFile(InputStream is, String mimeType) { message.setAssociatedFiles(attachmentStoredFiles); ApptentiveInternal.getInstance().getMessageManager().sendMessage(message); } catch (Exception e) { - ApptentiveLog.w("Error sending attachment file.", e); + ApptentiveLog.w(e, "Error in Apptentive.sendAttachmentFile(InputStream, String)"); MetricModule.sendError(e, null, null); } } + //endregion + /** * This method takes a unique event string, stores a record of that event having been visited, determines * if there is an interaction that is able to run for this event, and then runs it. If more than one interaction @@ -1117,7 +1156,7 @@ public static void sendAttachmentFile(InputStream is, String mimeType) { * @return true if the an interaction was shown, else false. */ public static synchronized boolean engage(Context context, String event) { - return EngagementModule.engage(context, "local", "app", null, event, null, null, (ExtendedData[]) null); + return engage(context, event, null, (ExtendedData[]) null); } /** @@ -1137,7 +1176,7 @@ public static synchronized boolean engage(Context context, String event) { * @return true if the an interaction was shown, else false. */ public static synchronized boolean engage(Context context, String event, Map customData) { - return EngagementModule.engage(context, "local", "app", null, event, null, customData, (ExtendedData[]) null); + return engage(context, event, customData, (ExtendedData[]) null); } /** @@ -1161,7 +1200,30 @@ public static synchronized boolean engage(Context context, String event, Map customData, ExtendedData... extendedData) { - return EngagementModule.engage(context, "local", "app", null, event, null, customData, extendedData); + try { + if (StringUtils.isNullOrEmpty(event)) { + ApptentiveLog.e("Unable to engage event: name is null or empty"); // TODO: throw an IllegalArgumentException instead? + return false; + } + if (context == null) { + ApptentiveLog.e("Unable to engage '%s' event: context is null", event); // TODO: throw an IllegalArgumentException instead? + return false; + } + if (!ApptentiveInternal.isApptentiveRegistered()) { + ApptentiveLog.e("Unable to engage '%s' event: Apptentive SDK is not initialized", event); + return false; + } + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + if (conversation == null) { + ApptentiveLog.w("Unable to engage '%s' event: no active conversation", event); + return false; + } + + return EngagementModule.engage(context, conversation, "local", "app", null, event, null, customData, extendedData); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while engaging '%s' event", event); + return false; + } } /** @@ -1188,8 +1250,11 @@ public static synchronized boolean willShowInteraction(String event) { */ public static synchronized boolean canShowInteraction(String event) { try { - return EngagementModule.canShowInteraction("local", "app", event); + if (ApptentiveInternal.isConversationActive()) { + return EngagementModule.canShowInteraction(ApptentiveInternal.getInstance().getConversation(), "app", event, "local"); + } } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.canShowInteraction()"); MetricModule.sendError(e, null, null); } return false; @@ -1205,12 +1270,217 @@ public static synchronized boolean canShowInteraction(String event) { * to call when the survey is finished. */ public static void setOnSurveyFinishedListener(OnSurveyFinishedListener listener) { - ApptentiveInternal internal = ApptentiveInternal.getInstance(); - if (internal != null) { - internal.setOnSurveyFinishedListener(listener); + try { + ApptentiveInternal internal = ApptentiveInternal.getInstance(); + if (internal != null) { + internal.setOnSurveyFinishedListener(listener); + } + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.setOnSurveyFinishedListener()"); + MetricModule.sendError(e, null, null); + } + } + + //region Login/Logout + + /** + * Starts login process asynchronously. This call returns immediately. Using this method requires + * you to implement JWT generation on your server. Please read about it in Apptentive's Android + * Integration Reference Guide. + * + * @param token A JWT signed by your server using the secret from your app's Apptentive settings. + * @param callback A LoginCallback, which will be called asynchronously when the login succeeds + * or fails. + */ + public static void login(String token, LoginCallback callback) { + try { + if (token == null) { + if (callback != null) { + callback.onLoginFail("token is null"); + } + return; + } + + final ApptentiveInternal instance = ApptentiveInternal.getInstance(); + if (instance == null) { + ApptentiveLog.e("Unable to login: Apptentive instance is not properly initialized"); + if (callback != null) { + callback.onLoginFail("Apptentive instance is not properly initialized"); + } + } else { + ApptentiveInternal.getInstance().login(token, callback); + } + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.login()"); + MetricModule.sendError(e, null, null); + } + } + + /** + * Callback interface login(). + */ + public interface LoginCallback { + /** + * Called when a login attempt has completed successfully. + */ + void onLoginFinish(); + + /** + * Called when a login attempt has failed. May be called synchronously, for example, if your JWT + * is missing the "sub" claim. + * + * @param errorMessage failure cause message + */ + void onLoginFail(String errorMessage); + } + + public static void logout() { + try { + final ApptentiveInternal instance = ApptentiveInternal.getInstance(); + if (instance == null) { + ApptentiveLog.e("Unable to logout: Apptentive instance is not properly initialized"); + } else { + instance.logout(); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception in Apptentive.logout()"); + MetricModule.sendError(e, null, null); + } + } + + /** + * Registers your listener with Apptentive. This listener is stored with a WeakReference, which + * means that you must store a static reference to the listener as long as you want it to live. + * One possible way to do this is to implement this listener with your Application class, or store + * one on your Application. + * + * This listener will alert you to authentication failures, so that you can either recover from + * expired or revoked JWTs, or fix your authentication implementation. + * @param listener A listener that will be called when there is an authentication failure other + * for the current logged in conversation. If the failure is for another + * conversation, or there is no active conversation, the listener is not called. + */ + public static void setAuthenticationFailedListener(AuthenticationFailedListener listener) { + try { + if (!ApptentiveInternal.checkRegistered()) { + return; + } + ApptentiveInternal.getInstance().setAuthenticationFailedListener(listener); + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.setUnreadMessagesListener()"); + MetricModule.sendError(e, null, null); + } + } + + public static void clearAuthenticationFailedListener() { + try { + if (!ApptentiveInternal.checkRegistered()) { + return; + } + ApptentiveInternal.getInstance().setAuthenticationFailedListener(null); + } catch (Exception e) { + ApptentiveLog.w(e, "Error in Apptentive.clearUnreadMessagesListener()"); + MetricModule.sendError(e, null, null); + } + } + + /** + * A Listener you can register globally for the app, that will be called when requests other than + * login fail for the active conversation. This includes failure to send queued data to + * Apptentive, and failure to fetch app configuration from Apptentive. + */ + public interface AuthenticationFailedListener { + void onAuthenticationFailed(AuthenticationFailedReason reason); + } + + /** + * A list of error codes you will encounter when a JWT failure for logged in conversations occurs. + */ + public enum AuthenticationFailedReason { + /** + * This should not happen. + */ + UNKNOWN, + /** + * Currently only the HS512 signature algorithm is supported. + */ + INVALID_ALGORITHM, + /** + * The JWT structure is constructed improperly (missing a part, etc.) + */ + MALFORMED_TOKEN, + /** + * The token is not signed properly, or can't be decoded. + */ + INVALID_TOKEN, + /** + * There is no "sub" property in the JWT claims. The "sub" is required, and should be an + * immutable, unique id for your user. + */ + MISSING_SUB_CLAIM, + /** + * The JWT "sub" claim does not match the one previously registered to the internal Apptentive + * conversation. Internal use only. + */ + MISMATCHED_SUB_CLAIM, + /** + * Internal use only. + */ + INVALID_SUB_CLAIM, + /** + * The expiration "exp" claim is expired. The "exp" claim is a UNIX timestamp in milliseconds. + * The JWT will receive this authentication failure when the "exp" time has elapsed. + */ + EXPIRED_TOKEN, + /** + * The JWT has been revoked. This happens after a successful logout. In such cases, you will + * need a new JWT to login. + */ + REVOKED_TOKEN, + /** + * The Apptentive Key field was not specified during registration. You can get this from your app's Apptentive + * settings. + */ + MISSING_APP_KEY, + /** + * The Apptentive Signature field was not specified during registration. You can get this from your app's Apptentive + * settings. + */ + MISSING_APP_SIGNATURE, + /** + * The Apptentive Key and Apptentive Signature fields do not match. Make sure you got them from + * the same app's Apptentive settings page. + */ + INVALID_KEY_SIGNATURE_PAIR; + + private String error; + + public String error() { + return error; + } + + public static AuthenticationFailedReason parse(String errorType, String error) { + try { + AuthenticationFailedReason ret = AuthenticationFailedReason.valueOf(errorType); + ret.error = error; + return ret; + } catch (Exception e) { + ApptentiveLog.w("Error parsing unknown Apptentive.AuthenticationFailedReason: %s", errorType); + } + return UNKNOWN; + } + + @Override + public String toString() { + return "AuthenticationFailedReason{" + + "error='" + error + '\'' + + "errorType='" + name() + '\'' + + '}'; } } + //endregion + /** *

This type represents a semantic version. It can be initialized * with a string or a long, and there is no limit to the number of parts your semantic version can @@ -1236,29 +1506,28 @@ public static void setOnSurveyFinishedListener(OnSurveyFinishedListener listener *

  • FF01
  • * */ - public static class Version extends JSONObject implements Comparable { + public static class Version implements Serializable, Comparable { + + private static final long serialVersionUID = 1L; + public static final String KEY_TYPE = "_type"; public static final String TYPE = "version"; + private String version; + public Version() { } - public Version(String json) throws JSONException { - super(json); + public Version(JSONObject json) throws JSONException { + this.version = json.optString(TYPE, null); } public Version(long version) { - super(); - setVersion(version); + this.version = Long.toString(version); } public void setVersion(String version) { - try { - put(KEY_TYPE, TYPE); - put(TYPE, version); - } catch (JSONException e) { - ApptentiveLog.e("Error creating Apptentive.Version.", e); - } + this.version = version; } public void setVersion(long version) { @@ -1266,7 +1535,17 @@ public void setVersion(long version) { } public String getVersion() { - return optString(TYPE, null); + return version; + } + + public void toJsonObject() { + JSONObject ret = new JSONObject(); + try { + ret.put(KEY_TYPE, TYPE); + ret.put(TYPE, version); + } catch (JSONException e) { + ApptentiveLog.e(e, "Error creating Apptentive.Version."); + } } @Override @@ -1313,31 +1592,38 @@ public String toString() { } } - public static class DateTime extends JSONObject implements Comparable { + public static class DateTime implements Serializable, Comparable { public static final String KEY_TYPE = "_type"; public static final String TYPE = "datetime"; public static final String SEC = "sec"; - public DateTime(String json) throws JSONException { - super(json); + private String sec; + + public DateTime(JSONObject json) throws JSONException { + this.sec = json.optString(SEC); } public DateTime(double dateTime) { - super(); setDateTime(dateTime); } public void setDateTime(double dateTime) { - try { - put(KEY_TYPE, TYPE); - put(SEC, dateTime); - } catch (JSONException e) { - ApptentiveLog.e("Error creating Apptentive.DateTime.", e); - } + sec = String.valueOf(dateTime); } public double getDateTime() { - return optDouble(SEC); + return Double.valueOf(sec); + } + + public JSONObject toJSONObject() { + JSONObject ret = new JSONObject(); + try { + ret.put(KEY_TYPE, TYPE); + ret.put(SEC, sec); + } catch (JSONException e) { + ApptentiveLog.e(e, "Error creating Apptentive.DateTime."); + } + return ret; } @Override diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveBaseActivity.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveBaseActivity.java index 1e31ea496..5a84645fe 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveBaseActivity.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveBaseActivity.java @@ -8,10 +8,10 @@ import com.apptentive.android.sdk.notifications.ApptentiveNotificationCenter; import com.apptentive.android.sdk.notifications.ApptentiveNotificationObserver; -import static com.apptentive.android.sdk.ApptentiveInternal.NOTIFICATION_INTERACTIONS_SHOULD_DISMISS; +import static com.apptentive.android.sdk.ApptentiveNotifications.*; /** A base class for any SDK activity */ -public class ApptentiveBaseActivity extends AppCompatActivity implements ApptentiveNotificationObserver { +public abstract class ApptentiveBaseActivity extends AppCompatActivity implements ApptentiveNotificationObserver { //region Activity life cycle @@ -31,11 +31,13 @@ protected void onDestroy() { //region Notifications - private void registerNotifications() { - ApptentiveNotificationCenter.defaultCenter().addObserver(NOTIFICATION_INTERACTIONS_SHOULD_DISMISS, this); + protected void registerNotifications() { + ApptentiveNotificationCenter.defaultCenter() + .addObserver(NOTIFICATION_INTERACTIONS_SHOULD_DISMISS, this) + .addObserver(NOTIFICATION_CONVERSATION_STATE_DID_CHANGE, this); } - private void unregisterNotification() { + protected void unregisterNotification() { ApptentiveNotificationCenter.defaultCenter().removeObserver(this); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveInternal.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveInternal.java index 830257903..be0396373 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveInternal.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveInternal.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -19,25 +19,19 @@ import android.content.pm.PackageManager; import android.content.res.Resources; import android.content.res.TypedArray; -import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; -import android.provider.Settings; import android.support.v4.content.ContextCompat; import android.text.TextUtils; -import com.apptentive.android.sdk.comm.ApptentiveClient; -import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; +import com.apptentive.android.sdk.Apptentive.LoginCallback; +import com.apptentive.android.sdk.comm.ApptentiveHttpClient; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.conversation.ConversationManager; import com.apptentive.android.sdk.lifecycle.ApptentiveActivityLifecycleCallbacks; -import com.apptentive.android.sdk.model.AppRelease; -import com.apptentive.android.sdk.model.CodePointStore; import com.apptentive.android.sdk.model.Configuration; -import com.apptentive.android.sdk.model.ConversationTokenRequest; -import com.apptentive.android.sdk.model.CustomData; -import com.apptentive.android.sdk.model.Device; -import com.apptentive.android.sdk.model.Event; -import com.apptentive.android.sdk.model.Person; -import com.apptentive.android.sdk.model.Sdk; +import com.apptentive.android.sdk.model.EventPayload; +import com.apptentive.android.sdk.model.LogoutPayload; import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.InteractionManager; import com.apptentive.android.sdk.module.engagement.interaction.model.MessageCenterInteraction; @@ -46,16 +40,17 @@ import com.apptentive.android.sdk.module.rating.IRatingProvider; import com.apptentive.android.sdk.module.rating.impl.GooglePlayRatingProvider; import com.apptentive.android.sdk.module.survey.OnSurveyFinishedListener; +import com.apptentive.android.sdk.notifications.ApptentiveNotification; import com.apptentive.android.sdk.notifications.ApptentiveNotificationCenter; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationObserver; +import com.apptentive.android.sdk.storage.AppRelease; import com.apptentive.android.sdk.storage.AppReleaseManager; import com.apptentive.android.sdk.storage.ApptentiveTaskManager; -import com.apptentive.android.sdk.storage.DeviceManager; -import com.apptentive.android.sdk.storage.PayloadSendWorker; -import com.apptentive.android.sdk.storage.PersonManager; +import com.apptentive.android.sdk.storage.Sdk; import com.apptentive.android.sdk.storage.SdkManager; -import com.apptentive.android.sdk.storage.VersionHistoryEntry; -import com.apptentive.android.sdk.storage.VersionHistoryStore; +import com.apptentive.android.sdk.storage.VersionHistoryItem; import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.StringUtils; import com.apptentive.android.sdk.util.Util; import org.json.JSONException; @@ -69,60 +64,66 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.atomic.AtomicBoolean; + +import static com.apptentive.android.sdk.ApptentiveLogTag.CONVERSATION; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_ACTIVITY_RESUMED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_ACTIVITY_STARTED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_APP_ENTERED_BACKGROUND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_APP_ENTERED_FOREGROUND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_AUTHENTICATION_FAILED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_CONVERSATION_WILL_LOGOUT; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_INTERACTIONS_FETCHED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_INTERACTIONS_SHOULD_DISMISS; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_ACTIVITY; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_AUTHENTICATION_FAILED_REASON; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_CONVERSATION; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_CONVERSATION_ID; +import static com.apptentive.android.sdk.debug.Assert.assertNotNull; +import static com.apptentive.android.sdk.debug.Assert.assertTrue; +import static com.apptentive.android.sdk.util.Constants.CONVERSATIONS_DIR; /** * This class contains only internal methods. These methods should not be access directly by the host app. */ -public class ApptentiveInternal { +public class ApptentiveInternal implements ApptentiveNotificationObserver { - /** - * Sent if user requested to close all interactions. - */ - public static final String NOTIFICATION_INTERACTIONS_SHOULD_DISMISS = "NOTIFICATION_INTERACTIONS_SHOULD_DISMISS"; + private final ApptentiveTaskManager taskManager; - static AtomicBoolean isApptentiveInitialized = new AtomicBoolean(false); - InteractionManager interactionManager; - MessageManager messageManager; - PayloadSendWorker payloadWorker; - ApptentiveTaskManager taskManager; - CodePointStore codePointStore; - ApptentiveActivityLifecycleCallbacks lifecycleCallbacks; + private final ApptentiveActivityLifecycleCallbacks lifecycleCallbacks; + private final ApptentiveHttpClient apptentiveHttpClient; + private final ConversationManager conversationManager; // These variables are initialized in Apptentive.register(), and so they are freely thereafter. If they are unexpectedly null, then if means the host app did not register Apptentive. - Context appContext; - int currentVersionCode; - String currentVersionName; - - boolean appIsInForeground; - boolean isAppDebuggable; - SharedPreferences prefs; - String apiKey; - String conversationToken; - String conversationId; - String personId; - String androidId; - String appPackageName; + private final Context appContext; + + // We keep a readonly reference to AppRelease object since it won't change at runtime + private final AppRelease appRelease; + + private boolean appIsInForeground; + private final SharedPreferences globalSharedPrefs; + private final String apptentiveKey; + private final String apptentiveSignature; + private String serverUrl; + private String androidId; // FIXME: remove this field (never used) + private String appPackageName; // toolbar theme specified in R.attr.apptentiveToolbarTheme - Resources.Theme apptentiveToolbarTheme; + private Resources.Theme apptentiveToolbarTheme; // app default appcompat theme res id, if specified in app AndroidManifest - int appDefaultAppCompatThemeId; + private int appDefaultAppCompatThemeId; - int statusBarColorDefault; - String defaultAppDisplayName = "this app"; + private int statusBarColorDefault; + private String defaultAppDisplayName = "this app"; // booleans to prevent starting multiple fetching asyncTasks simultaneously - AtomicBoolean isConversationTokenFetchPending = new AtomicBoolean(false); - AtomicBoolean isConfigurationFetchPending = new AtomicBoolean(false); - IRatingProvider ratingProvider; - Map ratingProviderArgs; - WeakReference onSurveyFinishedListener; + private IRatingProvider ratingProvider; + private Map ratingProviderArgs; + private WeakReference onSurveyFinishedListener; - final LinkedBlockingQueue interactionUpdateListeners = new LinkedBlockingQueue(); + private final LinkedBlockingQueue interactionUpdateListeners = new LinkedBlockingQueue(); - ExecutorService cachedExecutor; + private WeakReference authenticationFailedListenerRef = null; // Holds reference to the current foreground activity of the host app private WeakReference currentTaskStackTopActivity; @@ -130,9 +131,10 @@ public class ApptentiveInternal { // Used for temporarily holding customData that needs to be sent on the next message the consumer sends. private Map customData; - public static final String PUSH_ACTION = "action"; + private static final String PUSH_ACTION = "action"; + private static final String PUSH_CONVERSATION_ID = "conversation_id"; - public enum PushAction { + private enum PushAction { pmc, // Present Message Center. unknown; // Anything unknown will not be handled. @@ -149,59 +151,103 @@ public static PushAction parse(String name) { @SuppressLint("StaticFieldLeak") private static volatile ApptentiveInternal sApptentiveInternal; + // for unit testing + protected ApptentiveInternal() { + taskManager = null; + globalSharedPrefs = null; + apptentiveKey = null; + apptentiveSignature = null; + apptentiveHttpClient = null; + conversationManager = null; + appContext = null; + appRelease = null; + lifecycleCallbacks = null; + } + + private ApptentiveInternal(Application application, String apptentiveKey, String apptentiveSignature, String serverUrl) { + if (StringUtils.isNullOrEmpty(apptentiveKey)) { + throw new IllegalArgumentException("Apptentive Key is null or empty"); + } + + if (StringUtils.isNullOrEmpty(apptentiveSignature)) { + throw new IllegalArgumentException("Apptentive Signature is null or empty"); + } + + this.apptentiveKey = apptentiveKey; + this.apptentiveSignature = apptentiveSignature; + this.serverUrl = serverUrl; + + appContext = application.getApplicationContext(); + + globalSharedPrefs = application.getSharedPreferences(Constants.PREF_NAME, Context.MODE_PRIVATE); + apptentiveHttpClient = new ApptentiveHttpClient(apptentiveKey, apptentiveSignature, getEndpointBase(globalSharedPrefs)); + conversationManager = new ConversationManager(appContext, Util.getInternalDir(appContext, CONVERSATIONS_DIR, true)); + + appRelease = AppReleaseManager.generateCurrentAppRelease(application, this); + taskManager = new ApptentiveTaskManager(appContext, apptentiveHttpClient); + + lifecycleCallbacks = new ApptentiveActivityLifecycleCallbacks(); + ApptentiveNotificationCenter.defaultCenter() + .addObserver(NOTIFICATION_CONVERSATION_WILL_LOGOUT, this) + .addObserver(NOTIFICATION_AUTHENTICATION_FAILED, this); + } public static boolean isApptentiveRegistered() { - return (sApptentiveInternal != null); + return sApptentiveInternal != null; + } + + public static boolean isConversationActive() { + return sApptentiveInternal != null && sApptentiveInternal.getConversation() != null; } /** * Create a new or return a existing thread-safe instance of the Apptentive SDK. If this * or any other {@link #getInstance()} has already been called in the application's lifecycle, the - * API key will be ignored and the current instance will be returned. + * App key will be ignored and the current instance will be returned. *

    * This will be called from the application's onCreate(), before any other application objects have been * created. Since the time spent in this function directly impacts the performance of starting the first activity, * service, or receiver in the hosting app's process, the initialization of Apptentive is deferred to the first time * {@link #getInstance()} is called. * - * @param context the context of the app that is creating the instance - * @return An non-null instance of the Apptentive SDK + * @param application the context of the app that is creating the instance */ - public static ApptentiveInternal createInstance(Context context, final String apptentiveApiKey) { - if (sApptentiveInternal == null) { - synchronized (ApptentiveInternal.class) { - if (sApptentiveInternal == null && context != null) { - sApptentiveInternal = new ApptentiveInternal(); - isApptentiveInitialized.set(false); - sApptentiveInternal.appContext = context.getApplicationContext(); - sApptentiveInternal.prefs = sApptentiveInternal.appContext.getSharedPreferences(Constants.PREF_NAME, Context.MODE_PRIVATE); - - MessageManager msgManager = new MessageManager(); - PayloadSendWorker payloadWorker = new PayloadSendWorker(); - InteractionManager interactionMgr = new InteractionManager(); - ApptentiveTaskManager worker = new ApptentiveTaskManager(sApptentiveInternal.appContext); - - sApptentiveInternal.messageManager = msgManager; - sApptentiveInternal.payloadWorker = payloadWorker; - sApptentiveInternal.interactionManager = interactionMgr; - sApptentiveInternal.taskManager = worker; - sApptentiveInternal.codePointStore = new CodePointStore(); - sApptentiveInternal.cachedExecutor = Executors.newCachedThreadPool(); - sApptentiveInternal.apiKey = Util.trim(apptentiveApiKey); + static void createInstance(Application application, String apptentiveKey, String apptentiveSignature, final String serverUrl) { + if (application == null) { + throw new IllegalArgumentException("Application is null"); + } + + synchronized (ApptentiveInternal.class) { + if (sApptentiveInternal == null) { + + // trim spaces + apptentiveKey = Util.trim(apptentiveKey); + apptentiveSignature = Util.trim(apptentiveSignature); + + // if App key is not defined - try loading from AndroidManifest.xml + if (StringUtils.isNullOrEmpty(apptentiveKey)) { + apptentiveKey = Util.getManifestMetadataString(application, Constants.MANIFEST_KEY_APPTENTIVE_KEY); + // TODO: check if Apptentive Key is still empty + } + + // if App signature is not defined - try loading from AndroidManifest.xml + if (StringUtils.isNullOrEmpty(apptentiveSignature)) { + apptentiveSignature = Util.getManifestMetadataString(application, Constants.MANIFEST_KEY_APPTENTIVE_SIGNATURE); + // TODO: check if Apptentive Signature is still empty + } + + try { + ApptentiveLog.v("Initializing Apptentive instance: apptentiveKey=%s apptentiveSignature=%s", apptentiveKey, apptentiveSignature); + sApptentiveInternal = new ApptentiveInternal(application, apptentiveKey, apptentiveSignature, serverUrl); + sApptentiveInternal.start(); // TODO: check the result of this call + application.registerActivityLifecycleCallbacks(sApptentiveInternal.lifecycleCallbacks); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while initializing ApptentiveInternal instance"); } + } else { + ApptentiveLog.w("Apptentive instance is already initialized"); } } - return sApptentiveInternal; - } - - /** - * Retrieve the existing instance of the Apptentive class. If {@link Apptentive#register(Application)} is - * not called prior to this, it will only return null if context is null - * - * @return the existing instance of the Apptentive SDK fully initialized with API key, or a new instance if context is not null - */ - public static ApptentiveInternal getInstance(Context context) { - return createInstance((context == null) ? null : context, null); } /** @@ -211,18 +257,9 @@ public static ApptentiveInternal getInstance(Context context) { * @return the existing instance of the Apptentive SDK fully initialized with API key, or null */ public static ApptentiveInternal getInstance() { - // Lazy initialization, only once for each application launch when getInstance() is called for the 1st time - if (sApptentiveInternal != null && !isApptentiveInitialized.get()) { - synchronized (ApptentiveInternal.class) { - if (sApptentiveInternal != null && !isApptentiveInitialized.get()) { - isApptentiveInitialized.set(true); - if (!sApptentiveInternal.init()) { - ApptentiveLog.e("Apptentive init() failed"); - } - } - } + synchronized (ApptentiveInternal.class) { + return sApptentiveInternal; } - return sApptentiveInternal; } /** @@ -233,47 +270,15 @@ public static ApptentiveInternal getInstance() { */ public static void setInstance(ApptentiveInternal instance) { sApptentiveInternal = instance; - isApptentiveInitialized.set(false); - } - - /** - * Use this method to set or clear the internal app context (pass in null) - * Note: designed to be used for unit testing only - * - * @param appContext the new application context to be set to - */ - public static void setApplicationContext(Context appContext) { - synchronized (ApptentiveInternal.class) { - ApptentiveInternal internal = ApptentiveInternal.getInstance(); - if (internal != null) { - internal.appContext = appContext; - } - } - } - - /* Called by {@link #Apptentive.register()} to register global lifecycle - * callbacks, only if the callback hasn't been set yet. - */ - static void setLifeCycleCallback() { - if (sApptentiveInternal != null && sApptentiveInternal.lifecycleCallbacks == null) { - synchronized (ApptentiveInternal.class) { - if (sApptentiveInternal != null && sApptentiveInternal.lifecycleCallbacks == null && - sApptentiveInternal.appContext instanceof Application) { - sApptentiveInternal.lifecycleCallbacks = new ApptentiveActivityLifecycleCallbacks(); - ((Application) sApptentiveInternal.appContext).registerActivityLifecycleCallbacks(sApptentiveInternal.lifecycleCallbacks); - } - } - } } - /* * Set default theme whom Apptentive UI will inherit theme attributes from. Apptentive will only * inherit from an AppCompat theme * @param themeResId : resource id of the theme style definition, such as R.style.MyAppTheme * @return true if the theme is set for inheritance successfully. */ - public boolean setApplicationDefaultTheme(int themeResId) { + private boolean setApplicationDefaultTheme(int themeResId) { try { if (themeResId != 0) { // If passed theme res id does not exist, an exception would be thrown and caught @@ -316,18 +321,17 @@ public Context getApplicationContext() { } public int getApplicationVersionCode() { - return currentVersionCode; + return appRelease.getVersionCode(); } public String getApplicationVersionName() { - return currentVersionName; + return appRelease.getVersionName(); } public ApptentiveActivityLifecycleCallbacks getRegisteredLifecycleCallbacks() { return lifecycleCallbacks; } - /* Get the foreground activity from the current application, i.e. at the top of the task * It is tracked through {@link #onActivityStarted(Activity)} and {@link #onActivityStopped(Activity)} * @@ -342,151 +346,113 @@ public Activity getCurrentTaskStackTopActivity() { } public MessageManager getMessageManager() { - return messageManager; - } - - public InteractionManager getInteractionManager() { - return interactionManager; - } - - public PayloadSendWorker getPayloadWorker() { - return payloadWorker; + final Conversation conversation = getConversation(); + return conversation != null ? conversation.getMessageManager() : null; } public ApptentiveTaskManager getApptentiveTaskManager() { return taskManager; } - public CodePointStore getCodePointStore() { - return codePointStore; + public ConversationManager getConversationManager() { + return conversationManager; } public Resources.Theme getApptentiveToolbarTheme() { return apptentiveToolbarTheme; } - public int getDefaultStatusBarColor() { + int getDefaultStatusBarColor() { return statusBarColorDefault; } - public String getApptentiveConversationToken() { - return conversationToken; - } - - public String getApptentiveApiKey() { - return apiKey; + public Conversation getConversation() { + return conversationManager.getActiveConversation(); } - public String getDefaultAppDisplayName() { - return defaultAppDisplayName; + public String getApptentiveKey() { + return apptentiveKey; } - public boolean isApptentiveDebuggable() { - return isAppDebuggable; + public String getApptentiveSignature() { + return apptentiveSignature; } - public String getPersonId() { - return personId; - } - - public String getAndroidId() { - return androidId; - } - - public SharedPreferences getSharedPrefs() { - return prefs; + public String getServerUrl() { + if (serverUrl == null) { + return Constants.CONFIG_DEFAULT_SERVER_URL; + } + return serverUrl; } - public void addCustomDeviceData(String key, Object value) { - if (key == null || key.trim().length() == 0) { - return; - } - key = key.trim(); - CustomData customData = DeviceManager.loadCustomDeviceData(); - if (customData != null) { - try { - customData.put(key, value); - DeviceManager.storeCustomDeviceData(customData); - } catch (JSONException e) { - ApptentiveLog.w("Unable to add custom device data.", e); - } - } + public String getDefaultAppDisplayName() { + return defaultAppDisplayName; } - public void addCustomPersonData(String key, Object value) { - if (key == null || key.trim().length() == 0) { - return; - } - CustomData customData = PersonManager.loadCustomPersonData(); - if (customData != null) { - try { - customData.put(key, value); - PersonManager.storeCustomPersonData(customData); - } catch (JSONException e) { - ApptentiveLog.w("Unable to add custom person data.", e); - } - } + public boolean isApptentiveDebuggable() { + return appRelease.isDebug(); } - public void runOnWorkerThread(Runnable r) { - cachedExecutor.execute(r); + public SharedPreferences getGlobalSharedPrefs() { + return globalSharedPrefs; } - public void scheduleOnWorkerThread(Runnable r) { - cachedExecutor.submit(r); + // FIXME: remove app release from this class + public AppRelease getAppRelease() { + return appRelease; } - public void checkAndUpdateApptentiveConfigurations() { - // Initialize the Conversation Token, or fetch if needed. Fetch config it the token is available. - if (conversationToken == null || personId == null) { - asyncFetchConversationToken(); - } else { - asyncFetchAppConfigurationAndInteractions(); - } + public ApptentiveHttpClient getApptentiveHttpClient() { + return apptentiveHttpClient; } public void onAppLaunch(final Context appContext) { - EngagementModule.engageInternal(appContext, Event.EventLabel.app__launch.getLabelName()); + if (isConversationActive()) { + engageInternal(appContext, EventPayload.EventLabel.app__launch.getLabelName()); + } } public void onAppExit(final Context appContext) { - EngagementModule.engageInternal(appContext, Event.EventLabel.app__exit.getLabelName()); + if (isConversationActive()) { + engageInternal(appContext, EventPayload.EventLabel.app__exit.getLabelName()); + } } public void onActivityStarted(Activity activity) { if (activity != null) { // Set current foreground activity reference whenever a new activity is started - currentTaskStackTopActivity = new WeakReference(activity); - messageManager.setCurrentForegroundActivity(activity); - } + currentTaskStackTopActivity = new WeakReference<>(activity); - checkAndUpdateApptentiveConfigurations(); - - syncDevice(); - syncPerson(); + // Post a notification + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_ACTIVITY_STARTED, + NOTIFICATION_KEY_ACTIVITY, activity); + } } public void onActivityResumed(Activity activity) { if (activity != null) { // Set current foreground activity reference whenever a new activity is started - currentTaskStackTopActivity = new WeakReference(activity); - messageManager.setCurrentForegroundActivity(activity); - } + currentTaskStackTopActivity = new WeakReference<>(activity); + // Post a notification + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_ACTIVITY_RESUMED, + NOTIFICATION_KEY_ACTIVITY, activity); + } } public void onAppEnterForeground() { appIsInForeground = true; - payloadWorker.appWentToForeground(); - messageManager.appWentToForeground(); + + // Post a notification + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_APP_ENTERED_FOREGROUND); } public void onAppEnterBackground() { appIsInForeground = false; currentTaskStackTopActivity = null; - messageManager.setCurrentForegroundActivity(null); - payloadWorker.appWentToBackground(); - messageManager.appWentToBackground(); + + // Post a notification + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_APP_ENTERED_BACKGROUND); } /* Apply Apptentive styling layers to the theme to be used by interaction. The layers include @@ -546,27 +512,32 @@ public void updateApptentiveInteractionTheme(Resources.Theme interactionTheme, C apptentiveToolbarTheme.applyStyle(toolbarThemeId, true); } - public boolean init() { + private boolean start() { boolean bRet = true; - codePointStore.init(); /* If Message Center feature has never been used before, don't initialize message polling thread. * Message Center feature will be seen as used, if one of the following conditions has been met: * 1. Message Center has been opened for the first time * 2. The first Push is received which would open Message Center * 3. An unreadMessageCountListener() is set up */ - boolean featureEverUsed = prefs.getBoolean(Constants.PREF_KEY_MESSAGE_CENTER_FEATURE_USED, false); - if (featureEverUsed) { - messageManager.init(); + + long start = System.currentTimeMillis(); + boolean conversationLoaded = conversationManager.loadActiveConversation(getApplicationContext()); + ApptentiveLog.i(CONVERSATION, "Active conversation is%s loaded. Took %d ms", conversationLoaded ? "" : " not", System.currentTimeMillis() - start); + + if (conversationLoaded) { + Conversation activeConversation = conversationManager.getActiveConversation(); + // FIXME: don't accept the pull request before this one is resolved + // boolean featureEverUsed = activeConversation.isMessageCenterFeatureUsed(); + // if (featureEverUsed) { + // messageManager.init(); + // } } - conversationToken = prefs.getString(Constants.PREF_KEY_CONVERSATION_TOKEN, null); - conversationId = prefs.getString(Constants.PREF_KEY_CONVERSATION_ID, null); - personId = prefs.getString(Constants.PREF_KEY_PERSON_ID, null); + apptentiveToolbarTheme = appContext.getResources().newTheme(); boolean apptentiveDebug = false; String logLevelOverride = null; - String manifestApiKey = null; try { appPackageName = appContext.getPackageName(); PackageManager packageManager = appContext.getPackageManager(); @@ -575,7 +546,6 @@ public boolean init() { Bundle metaData = ai.metaData; if (metaData != null) { - manifestApiKey = Util.trim(metaData.getString(Constants.MANIFEST_KEY_APPTENTIVE_API_KEY)); logLevelOverride = Util.trim(metaData.getString(Constants.MANIFEST_KEY_APPTENTIVE_LOG_LEVEL)); apptentiveDebug = metaData.getBoolean(Constants.MANIFEST_KEY_APPTENTIVE_DEBUG); } @@ -583,24 +553,11 @@ public boolean init() { // Used for application theme inheritance if the theme is an AppCompat theme. setApplicationDefaultTheme(ai.theme); - AppRelease appRelease = AppRelease.generateCurrentAppRelease(appContext); - - isAppDebuggable = appRelease.getDebug(); - currentVersionCode = appRelease.getVersionCode(); - currentVersionName = appRelease.getVersionName(); - - VersionHistoryEntry lastVersionEntrySeen = VersionHistoryStore.getLastVersionSeen(); - - if (lastVersionEntrySeen == null) { - onVersionChanged(null, currentVersionCode, null, currentVersionName, appRelease); - } else { - int lastSeenVersionCode = lastVersionEntrySeen.getVersionCode(); - Apptentive.Version lastSeenVersionNameVersion = new Apptentive.Version(); - lastSeenVersionNameVersion.setVersion(lastVersionEntrySeen.getVersionName()); - if (!(currentVersionCode == lastSeenVersionCode) || !currentVersionName.equals(lastSeenVersionNameVersion.getVersion())) { - onVersionChanged(lastVersionEntrySeen.getVersionCode(), currentVersionCode, lastVersionEntrySeen.getVersionName(), currentVersionName, appRelease); - } + Conversation conversation = getConversation(); + if (conversation != null) { + checkSendVersionChanges(conversation); } + defaultAppDisplayName = packageManager.getApplicationLabel(packageManager.getApplicationInfo(packageInfo.packageName, 0)).toString(); // Prevent delayed run-time exception if the app upgrades from pre-2.0 and doesn't remove NetworkStateReceiver from manifest @@ -615,7 +572,7 @@ public boolean init() { } } catch (Exception e) { - ApptentiveLog.e("Unexpected error while reading application or package info.", e); + ApptentiveLog.e(e, "Unexpected error while reading application or package info."); bRet = false; } @@ -628,266 +585,112 @@ public boolean init() { ApptentiveLog.i("Overriding log level: %s", logLevelOverride); setMinimumLogLevel(ApptentiveLog.Level.parse(logLevelOverride)); } else { - if (isAppDebuggable) { + if (appRelease.isDebug()) { setMinimumLogLevel(ApptentiveLog.Level.VERBOSE); } } - ApptentiveLog.i("Debug mode enabled? %b", isAppDebuggable); - - String lastSeenSdkVersion = prefs.getString(Constants.PREF_KEY_LAST_SEEN_SDK_VERSION, ""); - if (!lastSeenSdkVersion.equals(Constants.APPTENTIVE_SDK_VERSION)) { - onSdkVersionChanged(appContext, lastSeenSdkVersion, Constants.APPTENTIVE_SDK_VERSION); - } - - // The apiKey can be passed in programmatically, or we can fallback to checking in the manifest. - if (TextUtils.isEmpty(apiKey) && !TextUtils.isEmpty(manifestApiKey)) { - apiKey = manifestApiKey; + ApptentiveLog.i("Debug mode enabled? %b", appRelease.isDebug()); + + // The app key can be passed in programmatically, or we can fallback to checking in the manifest. + if (TextUtils.isEmpty(apptentiveKey) || apptentiveKey.contains(Constants.EXAMPLE_APPTENTIVE_KEY_VALUE)) { + String errorMessage = "The Apptentive Key is not defined. You may provide your Apptentive Key in Apptentive.register(), or in as meta-data in your AndroidManifest.xml.\n" + + ""; + if (appRelease.isDebug()) { + throw new RuntimeException(errorMessage); + } else { + ApptentiveLog.e(errorMessage); + } + } else { + ApptentiveLog.d("Using cached Apptentive App Key"); } - if (TextUtils.isEmpty(apiKey) || apiKey.contains(Constants.EXAMPLE_API_KEY_VALUE)) { - String errorMessage = "The Apptentive API Key is not defined. You may provide your Apptentive API Key in Apptentive.register(), or in as meta-data in your AndroidManifest.xml.\n" + - ""; - if (isAppDebuggable) { + ApptentiveLog.d("Apptentive App Key: %s", apptentiveKey); + + // The app signature can be passed in programmatically, or we can fallback to checking in the manifest. + if (TextUtils.isEmpty(apptentiveSignature) || apptentiveSignature.contains(Constants.EXAMPLE_APPTENTIVE_SIGNATURE_VALUE)) { + String errorMessage = "The Apptentive Signature is not defined. You may provide your Apptentive Signature in Apptentive.register(), or in as meta-data in your AndroidManifest.xml.\n" + + ""; + if (appRelease.isDebug()) { throw new RuntimeException(errorMessage); } else { ApptentiveLog.e(errorMessage); } } else { - ApptentiveLog.d("Using cached Apptentive API Key"); + ApptentiveLog.d("Using cached Apptentive App Signature"); } - ApptentiveLog.d("Apptentive API Key: %s", apiKey); + ApptentiveLog.d("Apptentive App Signature: %s", apptentiveSignature); // Grab app info we need to access later on. - androidId = Settings.Secure.getString(appContext.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID); - ApptentiveLog.d("Android ID: ", androidId); ApptentiveLog.d("Default Locale: %s", Locale.getDefault().toString()); - ApptentiveLog.d("Conversation id: %s", prefs.getString(Constants.PREF_KEY_CONVERSATION_ID, "null")); return bRet; } - private void onVersionChanged(Integer previousVersionCode, Integer currentVersionCode, String previousVersionName, String currentVersionName, AppRelease currentAppRelease) { - ApptentiveLog.i("Version changed: Name: %s => %s, Code: %d => %d", previousVersionName, currentVersionName, previousVersionCode, currentVersionCode); - VersionHistoryStore.updateVersionHistory(currentVersionCode, currentVersionName); - if (previousVersionCode != null) { - AppReleaseManager.storeAppRelease(currentAppRelease); - taskManager.addPayload(currentAppRelease); + private void checkSendVersionChanges(Conversation conversation) { + if (conversation == null) { + ApptentiveLog.e("Can't check session data changes: session data is not initialized"); + return; } - invalidateCaches(); - } - private void onSdkVersionChanged(Context context, String previousSdkVersion, String currentSdkVersion) { - ApptentiveLog.i("SDK version changed: %s => %s", previousSdkVersion, currentSdkVersion); - context.getSharedPreferences(Constants.PREF_NAME, Context.MODE_PRIVATE).edit().putString(Constants.PREF_KEY_LAST_SEEN_SDK_VERSION, currentSdkVersion).apply(); - syncSdk(); - invalidateCaches(); - } + boolean appReleaseChanged = false; + boolean sdkChanged = false; - /** - * We want to make sure the app is using the latest configuration from the server if the app or sdk version changes. - */ - private void invalidateCaches() { - interactionManager.updateCacheExpiration(0); - Configuration config = Configuration.load(); - config.setConfigurationCacheExpirationMillis(System.currentTimeMillis()); - config.save(); - } + final VersionHistoryItem lastVersionItemSeen = conversation.getVersionHistory().getLastVersionSeen(); + final int currentVersionCode = appRelease.getVersionCode(); + final String currentVersionName = appRelease.getVersionName(); - private synchronized void asyncFetchConversationToken() { - if (isConversationTokenFetchPending.compareAndSet(false, true)) { - AsyncTask fetchConversationTokenTask = new AsyncTask() { - private Exception e = null; + Integer previousVersionCode = null; + String previousVersionName = null; - @Override - protected Boolean doInBackground(Void... params) { - try { - return fetchConversationToken(); - } catch (Exception e) { - // Hold onto the unhandled exception from fetchConversationToken() for later handling in UI thread - this.e = e; - } - return false; - } - - @Override - protected void onPostExecute(Boolean successful) { - if (e == null) { - // Update pending state on UI thread after finishing the task - ApptentiveLog.d("Fetching conversation token asyncTask finished. Successful? %b", successful); - isConversationTokenFetchPending.set(false); - if (successful) { - // Once token is fetched successfully, start asyncTasks to fetch global configuration, then interaction - asyncFetchAppConfigurationAndInteractions(); - } - } else { - ApptentiveLog.w("Unhandled Exception thrown from fetching conversation token asyncTask", e); - MetricModule.sendError(e, null, null); - } - } - - }; - - ApptentiveLog.i("Fetching conversation token asyncTask scheduled."); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - fetchConversationTokenTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } else { - fetchConversationTokenTask.execute(); - } + if (lastVersionItemSeen == null) { + appReleaseChanged = true; } else { - ApptentiveLog.v("Fetching Configuration pending"); - } - } - - - private boolean fetchConversationToken() { - ApptentiveLog.i("Fetching Configuration token task started."); - // Try to fetch a new one from the server. - ConversationTokenRequest request = new ConversationTokenRequest(); - - // Send the Device and Sdk now, so they are available on the server from the start. - request.setDevice(DeviceManager.storeDeviceAndReturnIt()); - request.setSdk(SdkManager.storeSdkAndReturnIt()); - request.setPerson(PersonManager.storePersonAndReturnIt()); - AppRelease currentAppRelease = AppRelease.generateCurrentAppRelease(appContext); - AppReleaseManager.storeAppRelease(currentAppRelease); - request.setAppRelease(currentAppRelease); + previousVersionCode = lastVersionItemSeen.getVersionCode(); + Apptentive.Version lastSeenVersionNameVersion = new Apptentive.Version(); - ApptentiveHttpResponse response = ApptentiveClient.getConversationToken(request); - if (response == null) { - ApptentiveLog.w("Got null response fetching ConversationToken."); - return false; - } - if (response.isSuccessful()) { - try { - JSONObject root = new JSONObject(response.getContent()); - String conversationToken = root.getString("token"); - ApptentiveLog.d("ConversationToken: " + conversationToken); - String conversationId = root.getString("id"); - ApptentiveLog.d("New Conversation id: %s", conversationId); - - if (conversationToken != null && !conversationToken.equals("")) { - setConversationToken(conversationToken); - setConversationId(conversationId); - } - String personId = root.getString("person_id"); - ApptentiveLog.d("PersonId: " + personId); - if (personId != null && !personId.equals("")) { - setPersonId(personId); - } - return true; - } catch (JSONException e) { - ApptentiveLog.e("Error parsing ConversationToken response json.", e); + previousVersionName = lastVersionItemSeen.getVersionName(); + lastSeenVersionNameVersion.setVersion(previousVersionName); + if (!(currentVersionCode == previousVersionCode) || !currentVersionName.equals(lastSeenVersionNameVersion.getVersion())) { + appReleaseChanged = true; } } - return false; - } - /** - * Fetches the global app configuration from the server and stores the keys into our SharedPreferences. - */ - private void fetchAppConfiguration() { - ApptentiveLog.i("Fetching new Configuration task started."); - ApptentiveHttpResponse response = ApptentiveClient.getAppConfiguration(); - try { - Map headers = response.getHeaders(); - if (headers != null) { - String cacheControl = headers.get("Cache-Control"); - Integer cacheSeconds = Util.parseCacheControlHeader(cacheControl); - if (cacheSeconds == null) { - cacheSeconds = Constants.CONFIG_DEFAULT_APP_CONFIG_EXPIRATION_DURATION_SECONDS; - } - ApptentiveLog.d("Caching configuration for %d seconds.", cacheSeconds); - Configuration config = new Configuration(response.getContent()); - config.setConfigurationCacheExpirationMillis(System.currentTimeMillis() + cacheSeconds * 1000); - config.save(); - } - } catch (JSONException e) { - ApptentiveLog.e("Error parsing app configuration from server.", e); + // TODO: Move this into a session became active handler. + final String lastSeenSdkVersion = conversation.getLastSeenSdkVersion(); + final String currentSdkVersion = Constants.APPTENTIVE_SDK_VERSION; + if (!TextUtils.equals(lastSeenSdkVersion, currentSdkVersion)) { + sdkChanged = true; } - } - - private void asyncFetchAppConfigurationAndInteractions() { - boolean force = isAppDebuggable; - - // Don't get the app configuration unless no pending fetch AND either forced, or the cache has expired. - if (isConfigurationFetchPending.compareAndSet(false, true) && (force || Configuration.load().hasConfigurationCacheExpired())) { - AsyncTask fetchConfigurationTask = new AsyncTask() { - // Hold onto the exception from the AsyncTask instance for later handling in UI thread - private Exception e = null; - @Override - protected Void doInBackground(Void... params) { - try { - fetchAppConfiguration(); - } catch (Exception e) { - this.e = e; - } - return null; - } - - @Override - protected void onPostExecute(Void v) { - // Update pending state on UI thread after finishing the task - ApptentiveLog.i("Fetching new Configuration asyncTask finished."); - isConfigurationFetchPending.set(false); - if (e != null) { - ApptentiveLog.w("Unhandled Exception thrown from fetching configuration asyncTask", e); - MetricModule.sendError(e, null, null); - } else { - // Check if need to start another asyncTask to fetch interaction after successfully fetching configuration - interactionManager.asyncFetchAndStoreInteractions(); - } - } - }; - - ApptentiveLog.i("Fetching new Configuration asyncTask scheduled."); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - fetchConfigurationTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } else { - fetchConfigurationTask.execute(); - } - } else { - ApptentiveLog.v("Using cached Configuration."); - // If configuration hasn't expire, then check if need to start another asyncTask to fetch interaction - interactionManager.asyncFetchAndStoreInteractions(); + if (appReleaseChanged) { + ApptentiveLog.i("Version changed: Name: %s => %s, Code: %d => %d", previousVersionName, currentVersionName, previousVersionCode, currentVersionCode); + conversation.getVersionHistory().updateVersionHistory(Util.currentTimeSeconds(), currentVersionCode, currentVersionName); } - } - /** - * Sends current Device to the server if it differs from the last time it was sent. - */ - void syncDevice() { - Device deviceInfo = DeviceManager.storeDeviceAndReturnDiff(); - if (deviceInfo != null) { - ApptentiveLog.d("Device info was updated."); - ApptentiveLog.v(deviceInfo.toString()); - taskManager.addPayload(deviceInfo); - } else { - ApptentiveLog.d("Device info was not updated."); + Sdk sdk = SdkManager.generateCurrentSdk(); + if (sdkChanged) { + ApptentiveLog.i("SDK version changed: %s => %s", lastSeenSdkVersion, currentSdkVersion); + conversation.setLastSeenSdkVersion(currentSdkVersion); + conversation.setSdk(sdk); } - } - /** - * Sends current SDK to the server. - */ - private void syncSdk() { - Sdk sdk = SdkManager.generateCurrentSdk(); - SdkManager.storeSdk(sdk); - ApptentiveLog.v(sdk.toString()); - taskManager.addPayload(sdk); + if (appReleaseChanged || sdkChanged) { + conversation.addPayload(AppReleaseManager.getPayload(sdk, appRelease)); + invalidateCaches(); + } } /** - * Sends current Person to the server if it differs from the last time it was sent. + * We want to make sure the app is using the latest configuration from the server if the app or sdk version changes. */ - private void syncPerson() { - Person person = PersonManager.storePersonAndReturnDiff(); - if (person != null) { - ApptentiveLog.d("Person was updated."); - ApptentiveLog.v(person.toString()); - taskManager.addPayload(person); - } else { - ApptentiveLog.d("Person was not updated."); + private void invalidateCaches() { + Conversation conversation = getConversation(); + if (conversation != null) { + conversation.setInteractionExpiration(0L); } + Configuration config = Configuration.load(); + config.setConfigurationCacheExpirationMillis(System.currentTimeMillis()); + config.save(); } public IRatingProvider getRatingProvider() { @@ -897,7 +700,7 @@ public IRatingProvider getRatingProvider() { return ratingProvider; } - public void setRatingProvider(IRatingProvider ratingProvider) { + void setRatingProvider(IRatingProvider ratingProvider) { this.ratingProvider = ratingProvider; } @@ -905,16 +708,16 @@ public Map getRatingProviderArgs() { return ratingProviderArgs; } - public void putRatingProviderArg(String key, String value) { + void putRatingProviderArg(String key, String value) { if (ratingProviderArgs == null) { - ratingProviderArgs = new HashMap(); + ratingProviderArgs = new HashMap<>(); } ratingProviderArgs.put(key, value); } - public void setOnSurveyFinishedListener(OnSurveyFinishedListener onSurveyFinishedListener) { + void setOnSurveyFinishedListener(OnSurveyFinishedListener onSurveyFinishedListener) { if (onSurveyFinishedListener != null) { - this.onSurveyFinishedListener = new WeakReference(onSurveyFinishedListener); + this.onSurveyFinishedListener = new WeakReference<>(onSurveyFinishedListener); } else { this.onSurveyFinishedListener = null; } @@ -932,10 +735,26 @@ public void removeInteractionUpdateListener(InteractionManager.InteractionUpdate interactionUpdateListeners.remove(listener); } + public void setAuthenticationFailedListener(Apptentive.AuthenticationFailedListener listener) { + authenticationFailedListenerRef = new WeakReference<>(listener); + } + + public void notifyAuthenticationFailedListener(Apptentive.AuthenticationFailedReason reason, String conversationIdOfFailedRequest) { + if (isConversationActive()) { + String activeConversationId = getConversation().getConversationId(); + if (StringUtils.equal(activeConversationId, conversationIdOfFailedRequest)) { + Apptentive.AuthenticationFailedListener listener = authenticationFailedListenerRef != null ? authenticationFailedListenerRef.get() : null; + if (listener != null) { + listener.onAuthenticationFailed(reason); + } + } + } + } + /** * Pass in a log level to override the default, which is {@link ApptentiveLog.Level#INFO} */ - public void setMinimumLogLevel(ApptentiveLog.Level level) { + private void setMinimumLogLevel(ApptentiveLog.Level level) { ApptentiveLog.overrideLogLevel(level); } @@ -1022,22 +841,6 @@ static String getApptentivePushNotificationData(Map pushData) { return null; } - boolean setPendingPushNotification(String apptentivePushData) { - if (apptentivePushData != null) { - ApptentiveLog.d("Saving Apptentive push notification data."); - prefs.edit().putString(Constants.PREF_KEY_PENDING_PUSH_NOTIFICATION, apptentivePushData).apply(); - messageManager.startMessagePreFetchTask(); - return true; - } - return false; - } - - boolean clearPendingPushNotification() { - ApptentiveLog.d("Clearing Apptentive push notification data."); - prefs.edit().remove(Constants.PREF_KEY_PENDING_PUSH_NOTIFICATION).apply(); - return true; - } - public void showAboutInternal(Context context, boolean showBrandingBand) { Intent intent = new Intent(); intent.setClass(context, ApptentiveViewActivity.class); @@ -1054,6 +857,25 @@ static PendingIntent generatePendingIntentFromApptentivePushData(String apptenti if (!TextUtils.isEmpty(apptentivePushData)) { try { JSONObject pushJson = new JSONObject(apptentivePushData); + + // we need to check if current user is actually the receiver of this notification + final String conversationId = pushJson.optString(PUSH_CONVERSATION_ID, null); + if (conversationId != null) { + final Conversation conversation = ApptentiveInternal.getInstance().getConversation(); + + // do we have a conversation right now? + if (conversation == null) { + ApptentiveLog.w("Can't generate pending intent from Apptentive push data: no active conversation"); + return null; + } + + // is it an actual receiver? + if (!StringUtils.equal(conversation.getConversationId(), conversationId)) { + ApptentiveLog.i("Can't generate pending intent from Apptentive push data: push conversation id doesn't match active conversation"); + return null; + } + } + ApptentiveInternal.PushAction action = ApptentiveInternal.PushAction.unknown; if (pushJson.has(ApptentiveInternal.PUSH_ACTION)) { action = ApptentiveInternal.PushAction.parse(pushJson.getString(ApptentiveInternal.PUSH_ACTION)); @@ -1072,7 +894,7 @@ static PendingIntent generatePendingIntentFromApptentivePushData(String apptenti ApptentiveLog.w("Unknown Apptentive push notification action: \"%s\"", action.name()); } } catch (JSONException e) { - ApptentiveLog.e("Error parsing JSON from push notification.", e); + ApptentiveLog.e(e, "Error parsing JSON from push notification."); MetricModule.sendError(e, "Parsing Apptentive Push", apptentivePushData); } } @@ -1089,12 +911,12 @@ public boolean showMessageCenterInternal(Context context, Map cu Object value = customData.get(key); if (value != null) { if (!(value instanceof String || - value instanceof Boolean || - value instanceof Long || - value instanceof Double || - value instanceof Float || - value instanceof Integer || - value instanceof Short)) { + value instanceof Boolean || + value instanceof Long || + value instanceof Double || + value instanceof Float || + value instanceof Integer || + value instanceof Short)) { ApptentiveLog.w("Removing invalid customData type: %s", value.getClass().getSimpleName()); keysIterator.remove(); } @@ -1102,7 +924,7 @@ public boolean showMessageCenterInternal(Context context, Map cu } } this.customData = customData; - interactionShown = EngagementModule.engageInternal(context, MessageCenterInteraction.DEFAULT_INTERNAL_EVENT_NAME); + interactionShown = engageInternal(context, MessageCenterInteraction.DEFAULT_INTERNAL_EVENT_NAME); if (!interactionShown) { this.customData = null; } @@ -1116,8 +938,14 @@ public void showMessageCenterFallback(Context context) { EngagementModule.launchMessageCenterErrorActivity(context); } + // FIXME: remove this method public boolean canShowMessageCenterInternal() { - return EngagementModule.canShowInteraction("com.apptentive", "app", MessageCenterInteraction.DEFAULT_INTERNAL_EVENT_NAME); + Conversation conversation = getConversation(); + return conversation != null && canShowMessageCenterInternal(conversation); + } + + public boolean canShowMessageCenterInternal(Conversation conversation) { + return EngagementModule.canShowInteraction(conversation, "app", MessageCenterInteraction.DEFAULT_INTERNAL_EVENT_NAME, "com.apptentive"); } public Map getAndClearCustomData() { @@ -1126,28 +954,13 @@ public Map getAndClearCustomData() { return customData; } - private void setConversationToken(String newConversationToken) { - conversationToken = newConversationToken; - prefs.edit().putString(Constants.PREF_KEY_CONVERSATION_TOKEN, conversationToken).apply(); - } - - private void setConversationId(String newConversationId) { - conversationId = newConversationId; - prefs.edit().putString(Constants.PREF_KEY_CONVERSATION_ID, conversationId).apply(); - } - - private void setPersonId(String newPersonId) { - personId = newPersonId; - prefs.edit().putString(Constants.PREF_KEY_PERSON_ID, personId).apply(); - } - public void resetSdkState() { - prefs.edit().clear().apply(); + globalSharedPrefs.edit().clear().apply(); taskManager.reset(appContext); - VersionHistoryStore.clear(); } public void notifyInteractionUpdated(boolean successful) { + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_INTERACTIONS_FETCHED); Iterator it = interactionUpdateListeners.iterator(); while (it.hasNext()) { @@ -1186,10 +999,110 @@ public static boolean checkRegistered() { return true; } + //region Helpers + + private String getEndpointBase(SharedPreferences prefs) { + String url = prefs.getString(Constants.PREF_KEY_SERVER_URL, null); + if (url == null) { + url = Constants.CONFIG_DEFAULT_SERVER_URL; + prefs.edit().putString(Constants.PREF_KEY_SERVER_URL, url).apply(); + } + return url; + } + + //endregion + + //region Login/Logout + + /** + * Flag indicating if login request is currently active (used to avoid multiple competing + * requests + */ + private boolean loginInProgress; + + /** + * Mutex object for synchronizing login request flag + */ + private final Object loginMutex = new Object(); + + void login(String token, final LoginCallback callback) { + synchronized (loginMutex) { + if (loginInProgress) { + if (callback != null) { + callback.onLoginFail("Another login request is currently in progress"); + } + return; + } + + loginInProgress = true; + + LoginCallback wrapperCallback = new LoginCallback() { + @Override + public void onLoginFinish() { + synchronized (loginMutex) { + assertTrue(loginInProgress); + try { + engageInternal(getApplicationContext(), "login"); + if (callback != null) { + callback.onLoginFinish(); + } + } finally { + loginInProgress = false; + } + } + } + + @Override + public void onLoginFail(String errorMessage) { + synchronized (loginMutex) { + assertTrue(loginInProgress); + try { + if (callback != null) { + callback.onLoginFail(errorMessage); + } + } finally { + loginInProgress = false; + } + } + } + }; + + conversationManager.login(token, wrapperCallback); + } + } + + void logout() { + conversationManager.logout(); + } + + //endregion + /** * Dismisses any currently-visible interactions. This method is for internal use and is subject to change. */ public static void dismissAllInteractions() { ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_INTERACTIONS_SHOULD_DISMISS); } + + @Override + public void onReceiveNotification(ApptentiveNotification notification) { + if (notification.hasName(NOTIFICATION_CONVERSATION_WILL_LOGOUT)) { + Conversation conversation = notification.getRequiredUserInfo(NOTIFICATION_KEY_CONVERSATION, Conversation.class); + conversation.addPayload(new LogoutPayload()); + } else if (notification.hasName(NOTIFICATION_AUTHENTICATION_FAILED)) { + String conversationIdOfFailedRequest = notification.getUserInfo(NOTIFICATION_KEY_CONVERSATION_ID, String.class); + Apptentive.AuthenticationFailedReason authenticationFailedReason = notification.getUserInfo(NOTIFICATION_KEY_AUTHENTICATION_FAILED_REASON, Apptentive.AuthenticationFailedReason.class); + notifyAuthenticationFailedListener(authenticationFailedReason, conversationIdOfFailedRequest); + } + } + + //region Engagement + + private boolean engageInternal(Context context, String eventName) { + Conversation conversation = getConversation(); + assertNotNull(conversation, "Attempted to engage '%s' internal event without an active conversation", eventName); + return conversation != null && EngagementModule.engageInternal(context, conversation, eventName); + } + + //endregion } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java index ab3d5c349..76ad854e0 100755 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLog.java @@ -20,7 +20,7 @@ public static void overrideLogLevel(Level level) { ApptentiveLog.logLevel = level; } - private static void doLog(Level level, Throwable throwable, String message, Object... args){ + private static void doLog(Level level, ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ if(canLog(level) && message != null){ if(args.length > 0){ try{ @@ -30,17 +30,34 @@ private static void doLog(Level level, Throwable throwable, String message, Obje level = Level.ERROR; } } + + String extra = null; + // add thread name if logging of the UI-thread if (Looper.getMainLooper() != null && Looper.getMainLooper().getThread() != Thread.currentThread()) { - message = String.format("[%s] %s", Thread.currentThread().getName(), message); + extra = '[' + Thread.currentThread().getName() + ']'; + } + + // custom tag + if (tag != null) { + if (extra == null) { + extra = '[' + tag.toString() + ']'; + } else { + extra += " [" + tag.toString() + ']'; + } + } + + if (extra != null) { + message = extra + " " + message; } - android.util.Log.println(level.getLevel(), TAG, message); + + android.util.Log.println(level.getAndroidLevel(), TAG, message); if(throwable != null){ if(throwable.getMessage() != null){ - android.util.Log.println(level.getLevel(), TAG, throwable.getMessage()); + android.util.Log.println(level.getAndroidLevel(), TAG, throwable.getMessage()); } while(throwable != null) { - android.util.Log.println(level.getLevel(), TAG, android.util.Log.getStackTraceString(throwable)); + android.util.Log.println(level.getAndroidLevel(), TAG, android.util.Log.getStackTraceString(throwable)); throwable = throwable.getCause(); } } @@ -51,86 +68,145 @@ public static boolean canLog(Level level) { return logLevel.canLog(level); } - public static void v(ApptentiveLogTag tag, String message, Object... args) { + public static void vv(ApptentiveLogTag tag, String message, Object... args) { + if (tag.enabled) { + doLog(Level.VERY_VERBOSE, tag, null, message, args); + } + } + public static void vv(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ if (tag.enabled) { - doLog(Level.VERBOSE, null, message, args); + doLog(Level.VERY_VERBOSE, tag, throwable, message, args); } } + public static void vv(String message, Object... args){ + doLog(Level.VERY_VERBOSE, null, null, message, args); + } + public static void vv(Throwable throwable, String message, Object... args){ + doLog(Level.VERY_VERBOSE, null, throwable, message, args); + } + public static void v(ApptentiveLogTag tag, String message, Object... args) { + if (tag.enabled) { + doLog(Level.VERBOSE, tag, null, message, args); + } + } + public static void v(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ + if (tag.enabled) { + doLog(Level.VERBOSE, tag, throwable, message, args); + } + } public static void v(String message, Object... args){ - doLog(Level.VERBOSE, null, message, args); + doLog(Level.VERBOSE, null, null, message, args); } - public static void v(String message, Throwable throwable, Object... args){ - doLog(Level.VERBOSE, throwable, message, args); + public static void v(Throwable throwable, String message, Object... args){ + doLog(Level.VERBOSE, null, throwable, message, args); } public static void d(ApptentiveLogTag tag, String message, Object... args){ if (tag.enabled) { - doLog(Level.DEBUG, null, message, args); + doLog(Level.DEBUG, tag, null, message, args); + } + } + public static void d(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ + if (tag.enabled) { + doLog(Level.DEBUG, tag, throwable, message, args); } } - public static void d(String message, Object... args){ - doLog(Level.DEBUG, null, message, args); + doLog(Level.DEBUG, null, null, message, args); } - public static void d(String message, Throwable throwable, Object... args){ - doLog(Level.DEBUG, throwable, message, args); + public static void d(Throwable throwable, String message, Object... args){ + doLog(Level.DEBUG, null, throwable, message, args); } public static void i(ApptentiveLogTag tag, String message, Object... args){ if (tag.enabled) { - doLog(Level.INFO, null, message, args); + doLog(Level.INFO, tag, null, message, args); + } + } + public static void i(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ + if (tag.enabled) { + doLog(Level.INFO, tag, throwable, message, args); } } public static void i(String message, Object... args){ - doLog(Level.INFO, null, message, args); + doLog(Level.INFO, null, null, message, args); } - public static void i(String message, Throwable throwable, Object... args){ - doLog(Level.INFO, throwable, message, args); + public static void i(Throwable throwable, String message, Object... args){ + doLog(Level.INFO, null, throwable, message, args); } public static void w(ApptentiveLogTag tag, String message, Object... args){ if (tag.enabled) { - doLog(Level.WARN, null, message, args); + doLog(Level.WARN, tag, null, message, args); + } + } + public static void w(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ + if (tag.enabled) { + doLog(Level.WARN, tag, throwable, message, args); } } public static void w(String message, Object... args){ - doLog(Level.WARN, null, message, args); + doLog(Level.WARN, null, null, message, args); } - public static void w(String message, Throwable throwable, Object... args){ - doLog(Level.WARN, throwable, message, args); + public static void w(Throwable throwable, String message, Object... args){ + doLog(Level.WARN, null, throwable, message, args); } - public static void e(String message, Object... args){ - doLog(Level.ERROR, null, message, args); + public static void e(ApptentiveLogTag tag, String message, Object... args){ + if (tag.enabled) { + doLog(Level.ERROR, tag, null, message, args); + } } - public static void e(String message, Throwable throwable, Object... args){ - doLog(Level.ERROR, throwable, message, args); + public static void e(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ + if (tag.enabled) { + doLog(Level.ERROR, tag, throwable, message, args); + } + } + public static void e(String message, Object... args){ + doLog(Level.ERROR, null, null, message, args); } public static void e(Throwable throwable, String message, Object... args){ - doLog(Level.ERROR, throwable, message, args); + doLog(Level.ERROR, null, throwable, message, args); } + public static void a(ApptentiveLogTag tag, String message, Object... args){ + if (tag.enabled) { + doLog(Level.ASSERT, tag, null, message, args); + } + } + public static void a(ApptentiveLogTag tag, Throwable throwable, String message, Object... args){ + if (tag.enabled) { + doLog(Level.ASSERT, tag, throwable, message, args); + } + } public static void a(String message, Object... args){ - doLog(Level.ASSERT, null, message, args); + doLog(Level.ASSERT, null, null, message, args); } - public static void a(String message, Throwable throwable, Object... args){ - doLog(Level.ASSERT, throwable, message, args); + public static void a(Throwable throwable, String message, Object... args){ + doLog(Level.ASSERT, null, throwable, message, args); } public enum Level { - VERBOSE(Log.VERBOSE), - DEBUG(Log.DEBUG), - INFO(Log.INFO), - WARN(Log.WARN), - ERROR(Log.ERROR), - ASSERT(Log.ASSERT), - DEFAULT(Log.INFO); + VERY_VERBOSE(1, Log.VERBOSE), + VERBOSE(Log.VERBOSE, Log.VERBOSE), + DEBUG(Log.DEBUG, Log.DEBUG), + INFO(Log.INFO, Log.INFO), + WARN(Log.WARN, Log.WARN), + ERROR(Log.ERROR, Log.ERROR), + ASSERT(Log.ASSERT, Log.ASSERT), + DEFAULT(Log.INFO, Log.INFO); private int level; + private int androidLevel; - private Level(int level) { + private Level(int level, int androidLevel) { this.level = level; + this.androidLevel = androidLevel; + } + + public int getAndroidLevel() { + return androidLevel; } public int getLevel() { @@ -151,7 +227,7 @@ public static Level parse(String level) { * @return true if "level" can be logged. */ public boolean canLog(Level level) { - return level.getLevel() >= getLevel(); + return level.level >= this.level; } } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java index d9d5b0339..69a54a7fa 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java @@ -1,8 +1,17 @@ package com.apptentive.android.sdk; public enum ApptentiveLogTag { - NETWORK, - CONVERSATION; + NETWORK(true), + CONVERSATION(true), + NOTIFICATIONS(true), + MESSAGES(true), + DATABASE(true), + PAYLOADS(true), + TESTER_COMMANDS(true); - public boolean enabled = true; + ApptentiveLogTag(boolean enabled) { + this.enabled = enabled; + } + + public boolean enabled; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveNotifications.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveNotifications.java new file mode 100644 index 000000000..8a5e315df --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveNotifications.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk; + +public class ApptentiveNotifications { + + /** + * Sent when conversation state changes (user logs out, etc) + */ + public static final String NOTIFICATION_CONVERSATION_STATE_DID_CHANGE = "CONVERSATION_STATE_DID_CHANGE"; // { conversation : Conversation } + + /** + * Sent when conversation is about to be logged out, to allow necessary tasks to be completed within the ending conversation. + */ + public static final String NOTIFICATION_CONVERSATION_WILL_LOGOUT = "CONVERSATION_WILL_LOGOUT"; // { conversation : Conversation } + + /** + * Sent if a new activity is started. + */ + public static final String NOTIFICATION_ACTIVITY_STARTED = "NOTIFICATION_ACTIVITY_STARTED"; // { activity : Activity } + + /** + * Sent if activity is resumed. + */ + public static final String NOTIFICATION_ACTIVITY_RESUMED = "NOTIFICATION_ACTIVITY_RESUMED"; // { activity : Activity } + + /** + * Sent if app entered foreground + */ + public static final String NOTIFICATION_APP_ENTERED_FOREGROUND = "NOTIFICATION_APP_ENTERED_FOREGROUND"; + + /** + * Sent if app entered background + */ + public static final String NOTIFICATION_APP_ENTERED_BACKGROUND = "NOTIFICATION_APP_ENTERED_BACKGROUND"; + + /** + * Sent before payload request is sent to the server + */ + public static final String NOTIFICATION_PAYLOAD_WILL_START_SEND = "NOTIFICATION_PAYLOAD_WILL_START_SEND"; // { payload: PayloadData } + + /** + * Sent after payload sending if finished (might be successful or not) + */ + public static final String NOTIFICATION_PAYLOAD_DID_FINISH_SEND = "NOTIFICATION_PAYLOAD_DID_FINISH_SEND"; // { successful : boolean, payload: PayloadData, responseCode: int, responseData: JSONObject } + + /** + * Sent if user requested to close all interactions. + */ + public static final String NOTIFICATION_INTERACTIONS_SHOULD_DISMISS = "NOTIFICATION_INTERACTIONS_SHOULD_DISMISS"; + + /** + * Sent when a request to the server fails with a 401, and external code needs to be notified. + */ + public static final String NOTIFICATION_AUTHENTICATION_FAILED = "NOTIFICATION_AUTHENTICATION_FAILED"; // { conversationId : String, authenticationFailedReason: AuthenticationFailedReason } + + /** + * Sent when interactions are fetched for any conversation. Used right now so espresso tests know when they can run. + */ + public static final String NOTIFICATION_INTERACTIONS_FETCHED = "NOTIFICATION_INTERACTIONS_FETCHED"; + + // keys + public static final String NOTIFICATION_KEY_SUCCESSFUL = "successful"; + public static final String NOTIFICATION_KEY_ACTIVITY = "activity"; + public static final String NOTIFICATION_KEY_CONVERSATION = "conversation"; + public static final String NOTIFICATION_KEY_CONVERSATION_ID = "conversationId"; + public static final String NOTIFICATION_KEY_AUTHENTICATION_FAILED_REASON = "authenticationFailedReason";// type: AuthenticationFailedReason + public static final String NOTIFICATION_KEY_PAYLOAD = "payload"; + public static final String NOTIFICATION_KEY_RESPONSE_CODE = "responseCode"; + public static final String NOTIFICATION_KEY_RESPONSE_DATA = "responseData"; +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveViewActivity.java b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveViewActivity.java index 4790e25f2..f66f7ddd7 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveViewActivity.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveViewActivity.java @@ -18,7 +18,6 @@ import android.os.Build; import android.os.Bundle; import android.support.v4.app.FragmentManager; - import android.support.v4.content.ContextCompat; import android.support.v4.content.IntentCompat; import android.support.v4.content.res.ResourcesCompat; @@ -31,6 +30,8 @@ import android.view.WindowManager; import com.apptentive.android.sdk.adapter.ApptentiveViewPagerAdapter; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.debug.Assert; import com.apptentive.android.sdk.model.FragmentFactory; import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.fragment.ApptentiveBaseFragment; @@ -39,9 +40,11 @@ import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; +import static com.apptentive.android.sdk.ApptentiveNotifications.*; +import static com.apptentive.android.sdk.debug.Assert.notNull; -public class ApptentiveViewActivity extends ApptentiveBaseActivity implements ApptentiveBaseFragment.OnFragmentTransitionListener{ +public class ApptentiveViewActivity extends ApptentiveBaseActivity implements ApptentiveBaseFragment.OnFragmentTransitionListener { private static final String FRAGMENT_TAG = "fragmentTag"; private int fragmentType; @@ -59,6 +62,12 @@ public class ApptentiveViewActivity extends ApptentiveBaseActivity implements Ap protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Conversation conversation = notNull(ApptentiveInternal.getInstance().getConversation()); + if (conversation == null) { + finish(); + return; + } + Bundle bundle = FragmentFactory.addDisplayModeToFragmentBundle(getIntent().getExtras()); boolean isInteractionModal = bundle.getBoolean(Constants.FragmentConfigKeys.MODAL); @@ -80,8 +89,8 @@ protected void onCreate(Bundle savedInstanceState) { if (fragmentType != Constants.FragmentTypes.UNKNOWN) { if (fragmentType == Constants.FragmentTypes.INTERACTION || - fragmentType == Constants.FragmentTypes.MESSAGE_CENTER_ERROR || - fragmentType == Constants.FragmentTypes.ABOUT) { + fragmentType == Constants.FragmentTypes.MESSAGE_CENTER_ERROR || + fragmentType == Constants.FragmentTypes.ABOUT) { bundle.putInt("toolbarLayoutId", R.id.apptentive_toolbar); if (newFragment == null) { newFragment = FragmentFactory.createFragmentInstance(bundle); @@ -97,7 +106,7 @@ protected void onCreate(Bundle savedInstanceState) { if (fragmentType == Constants.FragmentTypes.ENGAGE_INTERNAL_EVENT) { String eventName = getIntent().getStringExtra(Constants.FragmentConfigKeys.EXTRA); if (eventName != null) { - EngagementModule.engageInternal(this, eventName); + EngagementModule.engageInternal(this, conversation, eventName); } } finish(); @@ -106,7 +115,7 @@ protected void onCreate(Bundle savedInstanceState) { } } catch (Exception e) { - ApptentiveLog.e("Error creating ApptentiveViewActivity.", e); + ApptentiveLog.e(e, "Error creating ApptentiveViewActivity."); MetricModule.sendError(e, null, null); } @@ -126,14 +135,14 @@ protected void onCreate(Bundle savedInstanceState) { actionBar.setDisplayHomeAsUpEnabled(true); int navIconResId = newFragment.getToolbarNavigationIconResourceId(getTheme()); // Check if fragment may show an alternative navigation icon - if ( navIconResId != 0) { + if (navIconResId != 0) { /* In order for the alternative icon has the same color used by toolbar icon, * need to apply the same color in toolbar theme * By default colorControlNormal has same value as textColorPrimary defined in toolbar theme overlay */ final Drawable alternateUpArrow = ResourcesCompat.getDrawable(getResources(), - navIconResId, - getTheme()); + navIconResId, + getTheme()); int colorControlNormal = Util.getThemeColor(ApptentiveInternal.getInstance().getApptentiveToolbarTheme(), R.attr.colorControlNormal); alternateUpArrow.setColorFilter(colorControlNormal, PorterDuff.Mode.SRC_ATOP); @@ -144,6 +153,7 @@ protected void onCreate(Bundle savedInstanceState) { //current_tab = extra.getInt(SELECTED_TAB_EXTRA_KEY, 0); current_tab = 0; + newFragment.setConversation(conversation); addFragmentToAdapter(newFragment, newFragment.getTitle()); // Get the ViewPager and set it's PagerAdapter so that it can display items @@ -272,7 +282,7 @@ public void onFragmentTransition(ApptentiveBaseFragment currentFragment) { } private void applyApptentiveTheme(boolean isModalInteraction) { - // Update the activity theme to reflect current attributes + // Update the activity theme to reflect current attributes try { ApptentiveInternal.getInstance().updateApptentiveInteractionTheme(getTheme(), this); @@ -288,7 +298,7 @@ private void applyApptentiveTheme(boolean isModalInteraction) { setTaskDescription(taskDes); } } catch (Exception e) { - ApptentiveLog.e("Error apply Apptentive Theme.", e); + ApptentiveLog.e(e, "Error apply Apptentive Theme."); } } @@ -408,12 +418,26 @@ private void setStatusBarColor() { @Override public void onReceiveNotification(ApptentiveNotification notification) { - if (notification.getName().equals(ApptentiveInternal.NOTIFICATION_INTERACTIONS_SHOULD_DISMISS)) { - if (!isFinishing()) { - exitActivity(ApptentiveViewExitType.NOTIFICATION); + if (notification.hasName(NOTIFICATION_INTERACTIONS_SHOULD_DISMISS)) { + dismissActivity(); + } else if (notification.hasName(NOTIFICATION_CONVERSATION_STATE_DID_CHANGE)) { + final Conversation conversation = notification.getUserInfo(NOTIFICATION_KEY_CONVERSATION, Conversation.class); + Assert.assertNotNull(conversation, "Conversation expected to be not null"); + if (conversation != null && !conversation.hasActiveState()) { + dismissActivity(); } } } //endregion + + //region Helpers + + private void dismissActivity() { + if (!isFinishing()) { + exitActivity(ApptentiveViewExitType.NOTIFICATION); // TODO: different exit types for different notifications? + } + } + + //endregion } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveClient.java b/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveClient.java index b07adab94..ae156ef60 100755 --- a/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveClient.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveClient.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -11,12 +11,10 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.model.*; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.StringUtils; import com.apptentive.android.sdk.util.Util; -import com.apptentive.android.sdk.util.image.ImageUtil; import java.io.*; import java.net.HttpURLConnection; @@ -26,101 +24,69 @@ import java.util.*; import java.util.zip.GZIPInputStream; -public class ApptentiveClient { +import static com.apptentive.android.sdk.debug.Assert.notNull; - public static final int API_VERSION = 7; +public class ApptentiveClient { private static final String USER_AGENT_STRING = "Apptentive/%s (Android)"; // Format with SDK version string. - public static final int DEFAULT_HTTP_CONNECT_TIMEOUT = 45000; - public static final int DEFAULT_HTTP_SOCKET_TIMEOUT = 45000; - // Active API - private static final String ENDPOINT_CONVERSATION = "/conversation"; - private static final String ENDPOINT_CONVERSATION_FETCH = ENDPOINT_CONVERSATION + "?count=%s&after_id=%s&before_id=%s"; - private static final String ENDPOINT_MESSAGES = "/messages"; - private static final String ENDPOINT_EVENTS = "/events"; - private static final String ENDPOINT_DEVICES = "/devices"; - private static final String ENDPOINT_PEOPLE = "/people"; - private static final String ENDPOINT_CONFIGURATION = ENDPOINT_CONVERSATION + "/configuration"; - private static final String ENDPOINT_SURVEYS_POST = "/surveys/%s/respond"; + private static final String ENDPOINT_MESSAGES = "/conversations/%s/messages?count=%s&starts_after=%s&before_id=%s"; + private static final String ENDPOINT_CONFIGURATION = "/conversations/%s/configuration"; - private static final String ENDPOINT_INTERACTIONS = "/interactions"; + private static final String ENDPOINT_INTERACTIONS = "/conversations/%s/interactions"; // Deprecated API // private static final String ENDPOINT_RECORDS = ENDPOINT_BASE + "/records"; // private static final String ENDPOINT_SURVEYS_FETCH = ENDPOINT_BASE + "/surveys"; - public static ApptentiveHttpResponse getConversationToken(ConversationTokenRequest conversationTokenRequest) { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveApiKey(), ENDPOINT_CONVERSATION, Method.POST, conversationTokenRequest.toString()); - } - - public static ApptentiveHttpResponse getAppConfiguration() { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_CONFIGURATION, Method.GET, null); - } - /** * Gets all messages since the message specified by GUID was sent. * * @return An ApptentiveHttpResponse object with the HTTP response code, reason, and content. */ - public static ApptentiveHttpResponse getMessages(Integer count, String afterId, String beforeId) { - String uri = String.format(ENDPOINT_CONVERSATION_FETCH, count == null ? "" : count.toString(), afterId == null ? "" : afterId, beforeId == null ? "" : beforeId); - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), uri, Method.GET, null); - } - - public static ApptentiveHttpResponse postMessage(ApptentiveMessage apptentiveMessage) { - switch (apptentiveMessage.getType()) { - case CompoundMessage: { - CompoundMessage compoundMessage = (CompoundMessage) apptentiveMessage; - List associatedFiles = compoundMessage.getAssociatedFiles(); - return performMultipartFilePost(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_MESSAGES, apptentiveMessage.marshallForSending(), associatedFiles); - } - case unknown: - break; + public static ApptentiveHttpResponse getMessages(Conversation conversation, String afterId, String beforeId, Integer count) { + if (conversation == null) { + throw new IllegalStateException("Conversation is null"); } - return new ApptentiveHttpResponse(); - } - - public static ApptentiveHttpResponse postEvent(Event event) { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_EVENTS, Method.POST, event.marshallForSending()); - } - - public static ApptentiveHttpResponse putDevice(Device device) { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_DEVICES, Method.PUT, device.marshallForSending()); - } - public static ApptentiveHttpResponse putSdk(Sdk sdk) { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_CONVERSATION, Method.PUT, sdk.marshallForSending()); - } + final String conversationId = conversation.getConversationId(); + if (conversationId == null) { + throw new IllegalStateException("Conversation id is null"); + } - public static ApptentiveHttpResponse putAppRelease(AppRelease appRelease) { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_CONVERSATION, Method.PUT, appRelease.marshallForSending()); - } + final String conversationToken = conversation.getConversationToken(); + if (conversationToken == null) { + throw new IllegalStateException("Conversation token is null"); + } - public static ApptentiveHttpResponse putPerson(Person person) { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_PEOPLE, Method.PUT, person.marshallForSending()); + String uri = String.format(ENDPOINT_MESSAGES, conversationId, count == null ? "" : count.toString(), afterId == null ? "" : afterId, beforeId == null ? "" : beforeId); + return performHttpRequest(conversationToken, true, uri, Method.GET, null); } - public static ApptentiveHttpResponse postSurvey(SurveyResponse survey) { - String endpoint = String.format(ENDPOINT_SURVEYS_POST, survey.getId()); - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), endpoint, Method.POST, survey.marshallForSending()); - } + public static ApptentiveHttpResponse getInteractions(String conversationToken, String conversationId) { + if (StringUtils.isNullOrEmpty(conversationToken)) { + throw new IllegalArgumentException("Conversation token is null or empty"); + } - public static ApptentiveHttpResponse getInteractions() { - return performHttpRequest(ApptentiveInternal.getInstance().getApptentiveConversationToken(), ENDPOINT_INTERACTIONS, Method.GET, null); + if (StringUtils.isNullOrEmpty(conversationId)) { + throw new IllegalArgumentException("Conversation id is null or empty"); + } + final String endPoint = StringUtils.format(ENDPOINT_INTERACTIONS, conversationId); + return performHttpRequest(conversationToken, true, endPoint, Method.GET, null); } /** * Perform a Http request. * - * @param oauthToken authorization token for the current connection + * @param authToken authorization token for the current connection. Might be an OAuth token for legacy conversations, or Bearer JWT for modern conversations. + * @param bearer If true, the token is a bearer JWT, else it is an OAuth token. * @param uri server url. * @param method Get/Post/Put * @param body Data to be POSTed/Put, not used for GET * @return ApptentiveHttpResponse containing content and response returned from the server. */ - private static ApptentiveHttpResponse performHttpRequest(String oauthToken, String uri, Method method, String body) { + private static ApptentiveHttpResponse performHttpRequest(String authToken, boolean bearer, String uri, Method method, String body) { uri = getEndpointBase() + uri; ApptentiveLog.d("Performing %s request to %s", method.name(), uri); //ApptentiveLog.e("OAUTH Token: %s", oauthToken); @@ -138,13 +104,21 @@ private static ApptentiveHttpResponse performHttpRequest(String oauthToken, Stri connection.setRequestProperty("User-Agent", getUserAgentString()); connection.setRequestProperty("Connection", "Keep-Alive"); - connection.setConnectTimeout(DEFAULT_HTTP_CONNECT_TIMEOUT); - connection.setReadTimeout(DEFAULT_HTTP_SOCKET_TIMEOUT); - connection.setRequestProperty("Authorization", "OAuth " + oauthToken); + connection.setConnectTimeout(Constants.DEFAULT_CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(Constants.DEFAULT_READ_TIMEOUT_MILLIS); + if (bearer) { + connection.setRequestProperty("Authorization", "Bearer " + authToken); + } else { + connection.setRequestProperty("Authorization", "OAuth " + authToken); + } connection.setRequestProperty("Accept-Encoding", "gzip"); connection.setRequestProperty("Accept", "application/json"); - connection.setRequestProperty("X-API-Version", String.valueOf(API_VERSION)); + connection.setRequestProperty("X-API-Version", String.valueOf(Constants.API_VERSION)); + connection.setRequestProperty("APPTENTIVE-KEY", notNull(ApptentiveInternal.getInstance().getApptentiveKey())); + connection.setRequestProperty("APPTENTIVE-SIGNATURE", notNull(ApptentiveInternal.getInstance().getApptentiveSignature())); + + ApptentiveLog.vv("Headers: %s", connection.getRequestProperties()); switch (method) { case GET: connection.setRequestMethod("GET"); @@ -183,19 +157,19 @@ private static ApptentiveHttpResponse performHttpRequest(String oauthToken, Stri ApptentiveLog.w("Response: %s", ret.getContent()); } } catch (IllegalArgumentException e) { - ApptentiveLog.w("Error communicating with server.", e); + ApptentiveLog.w(e, "Error communicating with server."); } catch (SocketTimeoutException e) { - ApptentiveLog.w("Timeout communicating with server.", e); + ApptentiveLog.w(e, "Timeout communicating with server."); } catch (final MalformedURLException e) { - ApptentiveLog.w("MalformedUrlException", e); + ApptentiveLog.w(e, "MalformedUrlException"); } catch (final IOException e) { - ApptentiveLog.w("IOException", e); + ApptentiveLog.w(e, "IOException"); // Read the error response. try { ret.setContent(getErrorResponse(connection, ret.isZipped())); ApptentiveLog.w("Response: " + ret.getContent()); } catch (IOException ex) { - ApptentiveLog.w("Can't read error stream.", ex); + ApptentiveLog.w(ex, "Can't read error stream."); } } return ret; @@ -225,165 +199,6 @@ private static void sendPostPutRequest(final HttpURLConnection connection, final } } - private static ApptentiveHttpResponse performMultipartFilePost(String oauthToken, String uri, String postBody, List associatedFiles) { - uri = getEndpointBase() + uri; - ApptentiveLog.d("Performing multipart POST to %s", uri); - ApptentiveLog.d("Multipart POST body: %s", postBody); - - ApptentiveHttpResponse ret = new ApptentiveHttpResponse(); - if (!Util.isNetworkConnectionPresent()) { - ApptentiveLog.d("Network unavailable."); - return ret; - } - - String lineEnd = "\r\n"; - String twoHyphens = "--"; - String boundary = UUID.randomUUID().toString(); - - HttpURLConnection connection = null; - DataOutputStream os = null; - - try { - - // Set up the request. - URL url = new URL(uri); - connection = (HttpURLConnection) url.openConnection(); - connection.setDoInput(true); - connection.setDoOutput(true); - connection.setUseCaches(false); - connection.setConnectTimeout(DEFAULT_HTTP_CONNECT_TIMEOUT); - connection.setReadTimeout(DEFAULT_HTTP_SOCKET_TIMEOUT); - connection.setRequestMethod("POST"); - - connection.setRequestProperty("Content-Type", "multipart/mixed;boundary=" + boundary); - connection.setRequestProperty("Authorization", "OAuth " + oauthToken); - connection.setRequestProperty("Accept", "application/json"); - connection.setRequestProperty("X-API-Version", String.valueOf(API_VERSION)); - connection.setRequestProperty("User-Agent", getUserAgentString()); - - // Open an output stream. - os = new DataOutputStream(connection.getOutputStream()); - os.writeBytes(twoHyphens + boundary + lineEnd); - - // Write text message - os.writeBytes("Content-Disposition: form-data; name=\"message\"" + lineEnd); - // Indicate the character encoding is UTF-8 - os.writeBytes("Content-Type: text/plain;charset=UTF-8" + lineEnd); - - os.writeBytes(lineEnd); - // Explicitly encode message json in utf-8 - os.write(postBody.getBytes("UTF-8")); - os.writeBytes(lineEnd); - - - // Send associated files - if (associatedFiles != null) { - for (StoredFile storedFile : associatedFiles) { - FileInputStream fis = null; - try { - String cachedImagePathString = storedFile.getLocalFilePath(); - String originalFilePath = storedFile.getSourceUriOrPath(); - File cachedImageFile = new File(cachedImagePathString); - // No local cache found - if (!cachedImageFile.exists()) { - boolean bCachedCreated = false; - if (Util.isMimeTypeImage(storedFile.getMimeType())) { - // Create a scaled down version of original image - bCachedCreated = ImageUtil.createScaledDownImageCacheFile(originalFilePath, cachedImagePathString); - } else { - // For non-image file, just copy to a cache file - if (Util.createLocalStoredFile(originalFilePath, cachedImagePathString, null) != null) { - bCachedCreated = true; - } - } - - if (!bCachedCreated) { - continue; - } - } - os.writeBytes(twoHyphens + boundary + lineEnd); - StringBuilder requestText = new StringBuilder(); - String fileFullPathName = originalFilePath; - if (TextUtils.isEmpty(fileFullPathName)) { - fileFullPathName = cachedImagePathString; - } - requestText.append(String.format("Content-Disposition: form-data; name=\"file[]\"; filename=\"%s\"", fileFullPathName)).append(lineEnd); - requestText.append("Content-Type: ").append(storedFile.getMimeType()).append(lineEnd); - // Write file attributes - os.writeBytes(requestText.toString()); - os.writeBytes(lineEnd); - - fis = new FileInputStream(cachedImageFile); - - int bytesAvailable = fis.available(); - int maxBufferSize = 512 * 512; - int bufferSize = Math.min(bytesAvailable, maxBufferSize); - byte[] buffer = new byte[bufferSize]; - - // read image data 0.5MB at a time and write it into buffer - int bytesRead = fis.read(buffer, 0, bufferSize); - while (bytesRead > 0) { - os.write(buffer, 0, bufferSize); - bytesAvailable = fis.available(); - bufferSize = Math.min(bytesAvailable, maxBufferSize); - bytesRead = fis.read(buffer, 0, bufferSize); - } - } catch (IOException e) { - ApptentiveLog.d("Error writing file bytes to HTTP connection.", e); - ret.setBadPayload(true); - throw e; - } finally { - Util.ensureClosed(fis); - } - os.writeBytes(lineEnd); - } - } - os.writeBytes(twoHyphens + boundary + twoHyphens + lineEnd); - - os.flush(); - os.close(); - - ret.setCode(connection.getResponseCode()); - ret.setReason(connection.getResponseMessage()); - - // Read the normal response. - InputStream responseInputStream = null; - ByteArrayOutputStream byteArrayOutputStream = null; - try { - responseInputStream = connection.getInputStream(); - byteArrayOutputStream = new ByteArrayOutputStream(); - byte[] eBuf = new byte[1024]; - int eRead; - while (responseInputStream != null && (eRead = responseInputStream.read(eBuf, 0, 1024)) > 0) { - byteArrayOutputStream.write(eBuf, 0, eRead); - } - ret.setContent(byteArrayOutputStream.toString()); - } finally { - Util.ensureClosed(responseInputStream); - Util.ensureClosed(byteArrayOutputStream); - } - - ApptentiveLog.d("HTTP %d: %s", connection.getResponseCode(), connection.getResponseMessage()); - ApptentiveLog.v("Response: %s", ret.getContent()); - } catch (FileNotFoundException e) { - ApptentiveLog.e("Error getting file to upload.", e); - } catch (MalformedURLException e) { - ApptentiveLog.e("Error constructing url for file upload.", e); - } catch (SocketTimeoutException e) { - ApptentiveLog.w("Timeout communicating with server."); - } catch (IOException e) { - ApptentiveLog.e("Error executing file upload.", e); - try { - ret.setContent(getErrorResponse(connection, ret.isZipped())); - } catch (IOException ex) { - ApptentiveLog.w("Can't read error stream.", ex); - } - } finally { - Util.ensureClosed(os); - } - return ret; - } - private enum Method { GET, PUT, @@ -395,10 +210,10 @@ public static String getUserAgentString() { } private static String getEndpointBase() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); String url = prefs.getString(Constants.PREF_KEY_SERVER_URL, null); if (url == null) { - url = Constants.CONFIG_DEFAULT_SERVER_URL; + url = ApptentiveInternal.getInstance().getServerUrl(); prefs.edit().putString(Constants.PREF_KEY_SERVER_URL, url).apply(); } return url; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveHttpClient.java b/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveHttpClient.java new file mode 100644 index 000000000..fe30a945c --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveHttpClient.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.comm; + +import com.apptentive.android.sdk.model.ConversationTokenRequest; +import com.apptentive.android.sdk.model.PayloadData; +import com.apptentive.android.sdk.network.HttpJsonRequest; +import com.apptentive.android.sdk.network.HttpRequest; +import com.apptentive.android.sdk.network.HttpRequestManager; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.network.RawHttpRequest; +import com.apptentive.android.sdk.storage.AppRelease; +import com.apptentive.android.sdk.storage.AppReleaseManager; +import com.apptentive.android.sdk.storage.Device; +import com.apptentive.android.sdk.storage.DeviceManager; +import com.apptentive.android.sdk.storage.PayloadRequestSender; +import com.apptentive.android.sdk.storage.Sdk; +import com.apptentive.android.sdk.storage.SdkManager; +import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import static com.apptentive.android.sdk.debug.Assert.notNull; + +/** + * Class responsible for all client-server network communications using asynchronous HTTP requests + */ +public class ApptentiveHttpClient implements PayloadRequestSender { + + private static final String USER_AGENT_STRING = "Apptentive/%s (Android)"; // Format with SDK version string. + + private static final int DEFAULT_HTTP_CONNECT_TIMEOUT = 45000; + private static final int DEFAULT_HTTP_SOCKET_TIMEOUT = 45000; + + // Active API + private static final String ENDPOINT_CONVERSATION = "/conversation"; + private static final String ENDPOINT_LEGACY_CONVERSATION = "/conversation/token"; + private static final String ENDPOINT_LOG_IN_TO_EXISTING_CONVERSATION = "/conversations/%s/session"; + private static final String ENDPOINT_LOG_IN_TO_NEW_CONVERSATION = "/conversations"; + + private final String apptentiveKey; + private final String apptentiveSignature; + private final String serverURL; + private final String userAgentString; + private final HttpRequestManager httpRequestManager; + + public ApptentiveHttpClient(String apptentiveKey, String apptentiveSignature, String serverURL) { + if (StringUtils.isNullOrEmpty(apptentiveKey)) { + throw new IllegalArgumentException("Illegal Apptentive Key: '" + apptentiveKey + "'"); + } + + if (StringUtils.isNullOrEmpty(apptentiveSignature)) { + throw new IllegalArgumentException("Illegal Apptentive Signature: '" + apptentiveSignature + "'"); + } + + if (StringUtils.isNullOrEmpty(serverURL)) { + throw new IllegalArgumentException("Illegal server URL: '" + serverURL + "'"); + } + + this.httpRequestManager = new HttpRequestManager(); + this.apptentiveKey = apptentiveKey; + this.apptentiveSignature = apptentiveSignature; + this.serverURL = serverURL; + this.userAgentString = String.format(USER_AGENT_STRING, Constants.APPTENTIVE_SDK_VERSION); + } + + //region API Requests + + public HttpJsonRequest createConversationTokenRequest(ConversationTokenRequest conversationTokenRequest, HttpRequest.Listener listener) { + HttpJsonRequest request = createJsonRequest(ENDPOINT_CONVERSATION, conversationTokenRequest, HttpRequestMethod.POST); + request.addListener(listener); + return request; + } + + public HttpJsonRequest createLegacyConversationIdRequest(String conversationToken, HttpRequest.Listener listener) { + if (StringUtils.isNullOrEmpty(conversationToken)) { + throw new IllegalArgumentException("Conversation token is null or empty"); + } + + HttpJsonRequest request = createJsonRequest(ENDPOINT_LEGACY_CONVERSATION, new JSONObject(), HttpRequestMethod.GET); + request.setRequestProperty("Authorization", "OAuth " + conversationToken); + request.addListener(listener); + return request; + } + + public HttpJsonRequest createLoginRequest(String conversationId, String token, HttpRequest.Listener listener) { + if (token == null) { + throw new IllegalArgumentException("Token is null"); + } + + JSONObject json = new JSONObject(); + try { + json.put("token", token); + } catch (JSONException e) { + // Can't happen + } + String endPoint; + if (conversationId == null) { + endPoint = ENDPOINT_LOG_IN_TO_NEW_CONVERSATION; + + }else { + endPoint = StringUtils.format(ENDPOINT_LOG_IN_TO_EXISTING_CONVERSATION, conversationId); + } + HttpJsonRequest request = createJsonRequest(endPoint, json, HttpRequestMethod.POST); + request.addListener(listener); + return request; + } + + public HttpJsonRequest createFirstLoginRequest(String token, AppRelease appRelease, Sdk sdk, Device device, HttpRequest.Listener listener) { + if (token == null) { + throw new IllegalArgumentException("Token is null"); + } + + ConversationTokenRequest conversationTokenRequest = new ConversationTokenRequest(); + conversationTokenRequest.setSdkAndAppRelease(SdkManager.getPayload(sdk), AppReleaseManager.getPayload(appRelease)); + conversationTokenRequest.setDevice(DeviceManager.getDiffPayload(null, device)); + + try { + conversationTokenRequest.put("token", token); + } catch (JSONException e) { + // Can't happen + } + + HttpJsonRequest request = createJsonRequest(ENDPOINT_LOG_IN_TO_NEW_CONVERSATION, conversationTokenRequest, HttpRequestMethod.POST); + request.addListener(listener); + return request; + } + + /** + * Returns the first request with a given tag or null is not found + */ + public HttpRequest findRequest(String tag) { + return httpRequestManager.findRequest(tag); + } + + //endregion + + //region PayloadRequestSender + + @Override + public HttpRequest createPayloadSendRequest(PayloadData payload, HttpRequest.Listener listener) { + if (payload == null) { + throw new IllegalArgumentException("Payload is null"); + } + + HttpRequest request = createPayloadRequest(payload); + request.addListener(listener); + return request; + } + + private HttpRequest createPayloadRequest(PayloadData payload) { + final String authToken = payload.getAuthToken(); + final String httpPath = notNull(payload.getHttpRequestPath()); + final HttpRequestMethod requestMethod = notNull(payload.getHttpRequestMethod()); + final String contentType = notNull(payload.getContentType()); + + HttpRequest request = createRawRequest(httpPath, payload.getData(), requestMethod, contentType); + + // Encrypted requests don't use an Auth token on the request. It's stored in the encrypted body. + if (!StringUtils.isNullOrEmpty(authToken)) { + request.setRequestProperty("Authorization", "Bearer " + authToken); + } + + if (payload.isEncrypted()) { + request.setRequestProperty("APPTENTIVE-ENCRYPTED", Boolean.TRUE); + } + + return request; + } + + //endregion + + //region Helpers + + private HttpJsonRequest createJsonRequest(String endpoint, JSONObject json, HttpRequestMethod method) { + if (endpoint == null) { + throw new IllegalArgumentException("Endpoint is null"); + } + if (json == null) { + throw new IllegalArgumentException("Json is null"); + } + if (method == null) { + throw new IllegalArgumentException("Method is null"); + } + + String url = createEndpointURL(endpoint); + HttpJsonRequest request = new HttpJsonRequest(url, json); + setupRequestDefaults(request); + request.setMethod(method); + request.setRequestProperty("Content-Type", "application/json"); + return request; + } + + private RawHttpRequest createRawRequest(String endpoint, byte[] data, HttpRequestMethod method, String contentType) { + if (endpoint == null) { + throw new IllegalArgumentException("Endpoint is null"); + } + if (data == null) { + throw new IllegalArgumentException("Payload is null"); + } + if (method == null) { + throw new IllegalArgumentException("Method is null"); + } + if (contentType == null) { + throw new IllegalArgumentException("ContentType is null"); + } + + String url = createEndpointURL(endpoint); + RawHttpRequest request = new RawHttpRequest(url, data); + setupRequestDefaults(request); + request.setMethod(method); + request.setRequestProperty("Content-Type", contentType); + return request; + } + + private void setupRequestDefaults(HttpRequest request) { + request.setRequestManager(httpRequestManager); + request.setRequestProperty("User-Agent", userAgentString); + request.setRequestProperty("Connection", "Keep-Alive"); + request.setRequestProperty("Accept-Encoding", "gzip"); + request.setRequestProperty("Accept", "application/json"); + request.setRequestProperty("APPTENTIVE-KEY", apptentiveKey); + request.setRequestProperty("APPTENTIVE-SIGNATURE", apptentiveSignature); + request.setRequestProperty("X-API-Version", String.valueOf(Constants.API_VERSION)); + request.setConnectTimeout(DEFAULT_HTTP_CONNECT_TIMEOUT); + request.setReadTimeout(DEFAULT_HTTP_SOCKET_TIMEOUT); + } + + private String createEndpointURL(String uri) { + return serverURL + uri; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java new file mode 100644 index 000000000..eb826aa36 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java @@ -0,0 +1,663 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.conversation; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.apptentive.android.sdk.Apptentive; +import com.apptentive.android.sdk.ApptentiveInternal; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.comm.ApptentiveClient; +import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; +import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.model.DevicePayload; +import com.apptentive.android.sdk.model.Payload; +import com.apptentive.android.sdk.model.PersonPayload; +import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; +import com.apptentive.android.sdk.module.engagement.interaction.model.InteractionManifest; +import com.apptentive.android.sdk.module.engagement.interaction.model.Interactions; +import com.apptentive.android.sdk.module.engagement.interaction.model.Targets; +import com.apptentive.android.sdk.module.messagecenter.MessageManager; +import com.apptentive.android.sdk.storage.AppRelease; +import com.apptentive.android.sdk.storage.DataChangedListener; +import com.apptentive.android.sdk.storage.Device; +import com.apptentive.android.sdk.storage.DeviceManager; +import com.apptentive.android.sdk.storage.EncryptedFileSerializer; +import com.apptentive.android.sdk.storage.EventData; +import com.apptentive.android.sdk.storage.FileSerializer; +import com.apptentive.android.sdk.storage.IntegrationConfig; +import com.apptentive.android.sdk.storage.IntegrationConfigItem; +import com.apptentive.android.sdk.storage.Person; +import com.apptentive.android.sdk.storage.PersonManager; +import com.apptentive.android.sdk.storage.Sdk; +import com.apptentive.android.sdk.storage.SerializerException; +import com.apptentive.android.sdk.storage.VersionHistory; +import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.Destroyable; +import com.apptentive.android.sdk.util.RuntimeUtils; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.Util; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchTask; + +import org.json.JSONException; + +import java.io.File; + +import static com.apptentive.android.sdk.debug.Assert.assertFail; +import static com.apptentive.android.sdk.debug.Assert.assertNotNull; +import static com.apptentive.android.sdk.debug.Assert.notNull; +import static com.apptentive.android.sdk.debug.Tester.dispatchDebugEvent; +import static com.apptentive.android.sdk.ApptentiveLogTag.*; +import static com.apptentive.android.sdk.conversation.ConversationState.*; +import static com.apptentive.android.sdk.debug.TesterEvent.*; + +public class Conversation implements DataChangedListener, Destroyable { + + /** + * Conversation data for this class to manage + */ + private ConversationData conversationData; + + /** + * Encryption key for payloads. A hex encoded String. + */ + private String encryptionKey; + + /** + * Optional user id for logged-in conversations + */ + private String userId; + + /** + * Optional JWT for active conversations + */ + private String JWT; + + /** + * File which represents serialized conversation data on the disk + */ + private final File conversationDataFile; + + /** + * File which represents serialized messages data on the disk + */ + private final File conversationMessagesFile; + + /** + * Internal flag to turn interaction polling on and off fir testing. + */ + private Boolean pollForInteractions; + + private ConversationState state = ConversationState.UNDEFINED; + + private final MessageManager messageManager; + + // we keep references to the tasks in order to dispatch them only once + private final DispatchTask fetchInteractionsTask = new DispatchTask() { + @Override + protected void execute() { + final boolean updateSuccessful = fetchInteractionsSync(); + dispatchDebugEvent(EVT_CONVERSATION_FETCH_INTERACTIONS, updateSuccessful); + + // Update pending state on UI thread after finishing the task + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + if (hasActiveState()) { + ApptentiveInternal.getInstance().notifyInteractionUpdated(updateSuccessful); + } + } + }); + } + }; + + // we keep references to the tasks in order to dispatch them only once + private final DispatchTask saveConversationTask = new DispatchTask() { + @Override + protected void execute() { + try { + saveConversationData(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while saving conversation data"); + } + } + }; + + public Conversation(File conversationDataFile, File conversationMessagesFile) { + if (conversationDataFile == null) { + throw new IllegalArgumentException("Data file is null"); + } + if (conversationMessagesFile == null) { + throw new IllegalArgumentException("Messages file is null"); + } + + this.conversationDataFile = conversationDataFile; + this.conversationMessagesFile = conversationMessagesFile; + + conversationData = new ConversationData(); + + FileMessageStore messageStore = new FileMessageStore(conversationMessagesFile); + messageManager = new MessageManager(this, messageStore); // it's important to initialize message manager in a constructor since other SDK parts depend on it via Apptentive singleton + } + + public void startListeningForChanges() { + conversationData.setDataChangedListener(this); + } + + //region Payloads + + public void addPayload(Payload payload) { + payload.setLocalConversationIdentifier(notNull(getLocalIdentifier())); + payload.setConversationId(getConversationId()); + payload.setToken(getConversationToken()); + payload.setEncryptionKey(getEncryptionKey()); + + // FIXME: don't use singleton here + ApptentiveInternal.getInstance().getApptentiveTaskManager().addPayload(payload); + } + + //endregion + + //region Interactions + + /** + * Returns an Interaction for eventLabel if there is one that can be displayed. + */ + public Interaction getApplicableInteraction(String eventLabel) { + String targetsString = getTargets(); + if (targetsString != null) { + try { + Targets targets = new Targets(getTargets()); + String interactionId = targets.getApplicableInteraction(eventLabel); + if (interactionId != null) { + String interactionsString = getInteractions(); + if (interactionsString != null) { + Interactions interactions = new Interactions(interactionsString); + return interactions.getInteraction(interactionId); + } + } + } catch (JSONException e) { + ApptentiveLog.e(e, "Exception while getting applicable interaction: %s", eventLabel); + } + } + return null; + } + + boolean fetchInteractions(Context context) { + boolean cacheExpired = getInteractionExpiration() > Util.currentTimeSeconds(); + if (cacheExpired || RuntimeUtils.isAppDebuggable(context)) { + return DispatchQueue.backgroundQueue().dispatchAsyncOnce(fetchInteractionsTask); // do not allow multiple fetches at the same time + } + + ApptentiveLog.v(CONVERSATION, "Interaction cache is still valid"); + return false; + } + + /** + * Fetches interaction synchronously. Returns true if succeed. + */ + private boolean fetchInteractionsSync() { + ApptentiveLog.v(CONVERSATION, "Fetching Interactions"); + ApptentiveHttpResponse response = ApptentiveClient.getInteractions(getConversationToken(), getConversationId()); + + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + boolean updateSuccessful = true; + + // We weren't able to connect to the internet. + if (response.isException()) { + prefs.edit().putBoolean(Constants.PREF_KEY_MESSAGE_CENTER_SERVER_ERROR_LAST_ATTEMPT, false).apply(); + updateSuccessful = false; + } + // We got a server error. + else if (!response.isSuccessful()) { + prefs.edit().putBoolean(Constants.PREF_KEY_MESSAGE_CENTER_SERVER_ERROR_LAST_ATTEMPT, true).apply(); + updateSuccessful = false; + } + + if (updateSuccessful) { + String interactionsPayloadString = response.getContent(); + + // Store new integration cache expiration. + String cacheControl = response.getHeaders().get("Cache-Control"); + Integer cacheSeconds = Util.parseCacheControlHeader(cacheControl); + if (cacheSeconds == null) { + cacheSeconds = Constants.CONFIG_DEFAULT_INTERACTION_CACHE_EXPIRATION_DURATION_SECONDS; + } + setInteractionExpiration(Util.currentTimeSeconds() + cacheSeconds); + try { + InteractionManifest payload = new InteractionManifest(interactionsPayloadString); + Interactions interactions = payload.getInteractions(); + Targets targets = payload.getTargets(); + if (interactions != null && targets != null) { + setTargets(targets.toString()); + setInteractions(interactions.toString()); + } else { + ApptentiveLog.e(CONVERSATION, "Unable to save interactionManifest."); + } + } catch (JSONException e) { + ApptentiveLog.e(e, "Invalid InteractionManifest received."); + } + } + ApptentiveLog.v(CONVERSATION, "Fetching new Interactions task finished. Successful: %b", updateSuccessful); + + return updateSuccessful; + } + + public boolean isPollForInteractions() { + if (pollForInteractions == null) { + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + pollForInteractions = prefs.getBoolean(Constants.PREF_KEY_POLL_FOR_INTERACTIONS, true); + } + return pollForInteractions; + } + + public void setPollForInteractions(boolean pollForInteractions) { + this.pollForInteractions = pollForInteractions; + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + prefs.edit().putBoolean(Constants.PREF_KEY_POLL_FOR_INTERACTIONS, pollForInteractions).apply(); + } + + /** + * Made public for testing. There is no other reason to use this method directly. + */ + public void storeInteractionManifest(String interactionManifest) { + try { + InteractionManifest payload = new InteractionManifest(interactionManifest); + Interactions interactions = payload.getInteractions(); + Targets targets = payload.getTargets(); + if (interactions != null && targets != null) { + setTargets(targets.toString()); + setInteractions(interactions.toString()); + } else { + ApptentiveLog.e("Unable to save InteractionManifest."); + } + } catch (JSONException e) { + ApptentiveLog.w("Invalid InteractionManifest received."); + } + } + + //endregion + + //region Saving + + public void scheduleSaveConversationData() { + boolean scheduled = DispatchQueue.backgroundQueue().dispatchAsyncOnce(saveConversationTask, 100L); + if (scheduled) { + ApptentiveLog.d(CONVERSATION, "Scheduling conversation save."); + } else { + ApptentiveLog.d(CONVERSATION, "Conversation save already scheduled."); + } + } + + /** + * Saves conversation data to the disk synchronously. Returns true + * if succeed. + */ + private synchronized void saveConversationData() throws SerializerException { + if (ApptentiveLog.canLog(ApptentiveLog.Level.VERY_VERBOSE)) { + ApptentiveLog.vv(CONVERSATION, "Saving %sconversation data...", hasState(LOGGED_IN) ? "encrypted " : ""); + ApptentiveLog.vv(CONVERSATION, "EventData: %s", getEventData().toString()); + ApptentiveLog.vv(CONVERSATION, "Messages: %s", messageManager.getMessageStore().toString()); + } + long start = System.currentTimeMillis(); + + FileSerializer serializer; + if (!StringUtils.isNullOrEmpty(encryptionKey)) { + Assert.assertFalse(hasState(ANONYMOUS, ANONYMOUS_PENDING, LEGACY_PENDING)); + serializer = new EncryptedFileSerializer(conversationDataFile, encryptionKey); + } else { + Assert.assertTrue(hasState(ANONYMOUS, ANONYMOUS_PENDING, LEGACY_PENDING), "Unexpected conversation state: %s", getState()); + serializer = new FileSerializer(conversationDataFile); + } + + serializer.serialize(conversationData); + ApptentiveLog.vv(CONVERSATION, "Conversation data saved (took %d ms)", System.currentTimeMillis() - start); + } + + synchronized void loadConversationData() throws SerializerException { + long start = System.currentTimeMillis(); + + FileSerializer serializer; + if (!StringUtils.isNullOrEmpty(encryptionKey)) { + serializer = new EncryptedFileSerializer(conversationDataFile, encryptionKey); + } else { + serializer = new FileSerializer(conversationDataFile); + } + + ApptentiveLog.d(CONVERSATION, "Loading %sconversation data...", hasState(LOGGED_IN) ? "encrypted " : ""); + conversationData = (ConversationData) serializer.deserialize(); + ApptentiveLog.d(CONVERSATION, "Conversation data loaded (took %d ms)", System.currentTimeMillis() - start); + } + + //endregion + + //region Listeners + + @Override + public void onDataChanged() { + scheduleSaveConversationData(); + } + + //endregion + + //region Destroyable + + @Override + public void destroy() { + messageManager.destroy(); + } + + //endregion + + //region Diffs & Updates + + private final DispatchTask personUpdateTask = new DispatchTask() { + @Override + protected void execute() { + Person lastSentPerson = getLastSentPerson(); + Person currentPerson = getPerson(); + assertNotNull(currentPerson, "Current person object is null"); + PersonPayload personPayload = PersonManager.getDiffPayload(lastSentPerson, currentPerson); + if (personPayload != null) { + addPayload(personPayload); + setLastSentPerson(currentPerson != null ? currentPerson.clone() : null); + } + } + }; + + private final DispatchTask deviceUpdateTask = new DispatchTask() { + @Override + protected void execute() { + Device lastSentDevice = getLastSentDevice(); + Device currentDevice = getDevice(); + assertNotNull(currentDevice, "Current device object is null"); + DevicePayload devicePayload = DeviceManager.getDiffPayload(lastSentDevice, currentDevice); + if (devicePayload != null) { + addPayload(devicePayload); + setLastSentDevice(currentDevice != null ? currentDevice.clone() : null); + } + } + }; + + public void schedulePersonUpdate() { + DispatchQueue.mainQueue().dispatchAsyncOnce(personUpdateTask); + } + + public void scheduleDeviceUpdate() { + DispatchQueue.mainQueue().dispatchAsyncOnce(deviceUpdateTask); + } + + //endregion + + //region Getters & Setters + + public String getLocalIdentifier() { + return getConversationData().getLocalIdentifier(); + } + + public ConversationState getState() { + return state; + } + + public void setState(ConversationState state) { + // TODO: check if state transition would make sense (for example you should not be able to move from 'logged' state to 'anonymous', etc.) + this.state = state; + } + + /** + * Returns true if conversation is in the given state + */ + public boolean hasState(ConversationState s) { + return state.equals(s); + } + + /** + * Returns true if conversation is in one of the given states + */ + public boolean hasState(ConversationState... states) { + for (ConversationState s : states) { + if (s.equals(state)) { + return true; + } + } + return false; + } + + /** + * Returns true if conversation is in "active" state (after receiving server response) + */ + public boolean hasActiveState() { + return hasState(ConversationState.LOGGED_IN, ANONYMOUS); + } + + public String getConversationToken() { + return getConversationData().getConversationToken(); + } + + public void setConversationToken(String conversationToken) { + getConversationData().setConversationToken(conversationToken); + } + + public String getConversationId() { + return getConversationData().getConversationId(); + } + + public void setConversationId(String conversationId) { + getConversationData().setConversationId(conversationId); + } + + public Device getDevice() { + return getConversationData().getDevice(); + } + + public void setDevice(Device device) { + getConversationData().setDevice(device); + } + + public Device getLastSentDevice() { + return getConversationData().getLastSentDevice(); + } + + public void setLastSentDevice(Device lastSentDevice) { + getConversationData().setLastSentDevice(lastSentDevice); + } + + public Person getPerson() { + return getConversationData().getPerson(); + } + + public void setPerson(Person person) { + getConversationData().setPerson(person); + } + + public Person getLastSentPerson() { + return getConversationData().getLastSentPerson(); + } + + public void setLastSentPerson(Person lastSentPerson) { + getConversationData().setLastSentPerson(lastSentPerson); + } + + public Sdk getSdk() { + return getConversationData().getSdk(); + } + + public void setSdk(Sdk sdk) { + getConversationData().setSdk(sdk); + } + + public AppRelease getAppRelease() { + return getConversationData().getAppRelease(); + } + + public void setAppRelease(AppRelease appRelease) { + getConversationData().setAppRelease(appRelease); + } + + public EventData getEventData() { + return getConversationData().getEventData(); + } + + public void setEventData(EventData eventData) { + getConversationData().setEventData(eventData); + } + + public String getLastSeenSdkVersion() { + return getConversationData().getLastSeenSdkVersion(); + } + + public void setLastSeenSdkVersion(String lastSeenSdkVersion) { + getConversationData().setLastSeenSdkVersion(lastSeenSdkVersion); + } + + public VersionHistory getVersionHistory() { + return getConversationData().getVersionHistory(); + } + + public void setVersionHistory(VersionHistory versionHistory) { + getConversationData().setVersionHistory(versionHistory); + } + + public boolean isMessageCenterFeatureUsed() { + return getConversationData().isMessageCenterFeatureUsed(); + } + + public void setMessageCenterFeatureUsed(boolean messageCenterFeatureUsed) { + getConversationData().setMessageCenterFeatureUsed(messageCenterFeatureUsed); + } + + public boolean isMessageCenterWhoCardPreviouslyDisplayed() { + return getConversationData().isMessageCenterWhoCardPreviouslyDisplayed(); + } + + public void setMessageCenterWhoCardPreviouslyDisplayed(boolean messageCenterWhoCardPreviouslyDisplayed) { + getConversationData().setMessageCenterWhoCardPreviouslyDisplayed(messageCenterWhoCardPreviouslyDisplayed); + } + + public String getMessageCenterPendingMessage() { + return getConversationData().getMessageCenterPendingMessage(); + } + + public void setMessageCenterPendingMessage(String messageCenterPendingMessage) { + getConversationData().setMessageCenterPendingMessage(messageCenterPendingMessage); + } + + public String getMessageCenterPendingAttachments() { + return getConversationData().getMessageCenterPendingAttachments(); + } + + public void setMessageCenterPendingAttachments(String messageCenterPendingAttachments) { + getConversationData().setMessageCenterPendingAttachments(messageCenterPendingAttachments); + } + + public String getTargets() { + return getConversationData().getTargets(); + } + + public void setTargets(String targets) { + getConversationData().setTargets(targets); + } + + public String getInteractions() { + return getConversationData().getInteractions(); + } + + public void setInteractions(String interactions) { + getConversationData().setInteractions(interactions); + } + + public double getInteractionExpiration() { + return getConversationData().getInteractionExpiration(); + } + + public void setInteractionExpiration(double interactionExpiration) { + getConversationData().setInteractionExpiration(interactionExpiration); + } + + // this is a synchronization hack: both save/load conversation data are synchronized so we can't + // modify conversation data while it's being serialized/deserialized + private synchronized ConversationData getConversationData() { + return conversationData; + } + + public MessageManager getMessageManager() { + return messageManager; + } + + synchronized File getConversationDataFile() { + return conversationDataFile; + } + + synchronized File getConversationMessagesFile() { + return conversationMessagesFile; + } + + public String getEncryptionKey() { + return encryptionKey; + } + + void setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; + } + + String getUserId() { + return userId; + } + + void setUserId(String userId) { + this.userId = userId; + } + + public void setPushIntegration(int pushProvider, String token) { + ApptentiveLog.v(CONVERSATION, "Setting push provider: %d with token %s", pushProvider, token); + IntegrationConfig integrationConfig = getDevice().getIntegrationConfig(); + IntegrationConfigItem item = new IntegrationConfigItem(); + item.setToken(token); + switch (pushProvider) { + case Apptentive.PUSH_PROVIDER_APPTENTIVE: + integrationConfig.setApptentive(item); + break; + case Apptentive.PUSH_PROVIDER_PARSE: + integrationConfig.setParse(item); + break; + case Apptentive.PUSH_PROVIDER_URBAN_AIRSHIP: + integrationConfig.setUrbanAirship(item); + break; + case Apptentive.PUSH_PROVIDER_AMAZON_AWS_SNS: + integrationConfig.setAmazonAwsSns(item); + break; + default: + ApptentiveLog.e("Invalid pushProvider: %d", pushProvider); + break; + } + scheduleDeviceUpdate(); + } + + /** + * Checks the internal consistency of the conversation object (temporary solution) + */ + void checkInternalConsistency() throws IllegalStateException { + switch (state) { + case LOGGED_IN: + if (StringUtils.isNullOrEmpty(encryptionKey)) { + assertFail("Missing encryption key"); + throw new IllegalStateException("Missing encryption key"); + } + if (StringUtils.isNullOrEmpty(userId)) { + assertFail("Missing user id"); + throw new IllegalStateException("Missing user id"); + } + break; + default: + break; + } + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationData.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationData.java new file mode 100644 index 000000000..85449a37c --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationData.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.conversation; + +import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.storage.AppRelease; +import com.apptentive.android.sdk.storage.DataChangedListener; +import com.apptentive.android.sdk.storage.Device; +import com.apptentive.android.sdk.storage.EventData; +import com.apptentive.android.sdk.storage.Person; +import com.apptentive.android.sdk.storage.Saveable; +import com.apptentive.android.sdk.storage.Sdk; +import com.apptentive.android.sdk.storage.VersionHistory; +import com.apptentive.android.sdk.util.StringUtils; + +import java.util.UUID; + +public class ConversationData implements Saveable, DataChangedListener { + + private static final long serialVersionUID = 1L; + private String localIdentifier; + private String conversationToken; + private String conversationId; + private Device device; + private Device lastSentDevice; + private Person person; + private Person lastSentPerson; + private Sdk sdk; + private AppRelease appRelease; + private EventData eventData; + private String lastSeenSdkVersion; + private VersionHistory versionHistory; + private boolean messageCenterFeatureUsed; + private boolean messageCenterWhoCardPreviouslyDisplayed; + private String messageCenterPendingMessage; + private String messageCenterPendingAttachments; + private String targets; + private String interactions; + private double interactionExpiration; + + public ConversationData() { + this.localIdentifier = UUID.randomUUID().toString(); + this.device = new Device(); + this.person = new Person(); + this.sdk = new Sdk(); + this.appRelease = new AppRelease(); + this.eventData = new EventData(); + this.versionHistory = new VersionHistory(); + } + + //region Listeners + + private transient DataChangedListener listener; + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + device.setDataChangedListener(this); + person.setDataChangedListener(this); + eventData.setDataChangedListener(this); + versionHistory.setDataChangedListener(this); + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + @Override + public void onDataChanged() { + notifyDataChanged(); + } + //endregion + + //region Getters & Setters + + public String getLocalIdentifier() { + return localIdentifier; + } + + public String getConversationToken() { + return conversationToken; + } + + public void setConversationToken(String conversationToken) { + if (!StringUtils.equal(this.conversationToken, conversationToken)) { + this.conversationToken = conversationToken; + notifyDataChanged(); + } + } + + public String getConversationId() { + return conversationId; + } + + public void setConversationId(String conversationId) { + if (conversationId == null) { + throw new IllegalArgumentException("Conversation id is null"); + } + + if (!StringUtils.equal(this.conversationId, conversationId)) { + this.conversationId = conversationId; + notifyDataChanged(); + } + } + + public Device getDevice() { + return device; + } + + public void setDevice(Device device) { + Assert.assertNotNull(device, "Device may not be null."); + this.device = device; + device.setDataChangedListener(this); + notifyDataChanged(); + } + + public Device getLastSentDevice() { + return lastSentDevice; + } + + public void setLastSentDevice(Device lastSentDevice) { + this.lastSentDevice = lastSentDevice; + this.lastSentDevice.setDataChangedListener(this); + notifyDataChanged(); + } + + public Person getPerson() { + return person; + } + + public void setPerson(Person person) { + Assert.assertNotNull(person, "Person may not be null."); + this.person = person; + this.person.setDataChangedListener(this); + notifyDataChanged(); + } + + public Person getLastSentPerson() { + return lastSentPerson; + } + + public void setLastSentPerson(Person lastSentPerson) { + this.lastSentPerson = lastSentPerson; + this.lastSentPerson.setDataChangedListener(this); + notifyDataChanged(); + } + + public Sdk getSdk() { + return sdk; + } + + public void setSdk(Sdk sdk) { + this.sdk = sdk; + notifyDataChanged(); + } + + public AppRelease getAppRelease() { + return appRelease; + } + + public void setAppRelease(AppRelease appRelease) { + this.appRelease = appRelease; + notifyDataChanged(); + } + + public EventData getEventData() { + return eventData; + } + + public void setEventData(EventData eventData) { + this.eventData = eventData; + this.eventData.setDataChangedListener(this); + notifyDataChanged(); + } + + public String getLastSeenSdkVersion() { + return lastSeenSdkVersion; + } + + public void setLastSeenSdkVersion(String lastSeenSdkVersion) { + this.lastSeenSdkVersion = lastSeenSdkVersion; + notifyDataChanged(); + } + + public VersionHistory getVersionHistory() { + return versionHistory; + } + + public void setVersionHistory(VersionHistory versionHistory) { + this.versionHistory = versionHistory; + this.versionHistory.setDataChangedListener(this); + notifyDataChanged(); + } + + public boolean isMessageCenterFeatureUsed() { + return messageCenterFeatureUsed; + } + + public void setMessageCenterFeatureUsed(boolean messageCenterFeatureUsed) { + if (this.messageCenterFeatureUsed != messageCenterFeatureUsed) { + this.messageCenterFeatureUsed = messageCenterFeatureUsed; + notifyDataChanged(); + } + } + + public boolean isMessageCenterWhoCardPreviouslyDisplayed() { + return messageCenterWhoCardPreviouslyDisplayed; + } + + public void setMessageCenterWhoCardPreviouslyDisplayed(boolean messageCenterWhoCardPreviouslyDisplayed) { + if (this.messageCenterWhoCardPreviouslyDisplayed != messageCenterWhoCardPreviouslyDisplayed) { + this.messageCenterWhoCardPreviouslyDisplayed = messageCenterWhoCardPreviouslyDisplayed; + notifyDataChanged(); + } + } + + public String getMessageCenterPendingMessage() { + return messageCenterPendingMessage; + } + + public void setMessageCenterPendingMessage(String messageCenterPendingMessage) { + if (!StringUtils.equal(this.messageCenterPendingMessage, messageCenterPendingMessage)) { + this.messageCenterPendingMessage = messageCenterPendingMessage; + notifyDataChanged(); + } + } + + public String getMessageCenterPendingAttachments() { + return messageCenterPendingAttachments; + } + + public void setMessageCenterPendingAttachments(String messageCenterPendingAttachments) { + if (!StringUtils.equal(this.messageCenterPendingAttachments, messageCenterPendingAttachments)) { + this.messageCenterPendingAttachments = messageCenterPendingAttachments; + notifyDataChanged(); + } + } + + public String getTargets() { + return targets; + } + + public void setTargets(String targets) { + if (!StringUtils.equal(this.targets, targets)) { + this.targets = targets; + notifyDataChanged(); + } + } + + public String getInteractions() { + return interactions; + } + + public void setInteractions(String interactions) { + if (!StringUtils.equal(this.interactions, interactions)) { + this.interactions = interactions; + notifyDataChanged(); + } + } + + public double getInteractionExpiration() { + return interactionExpiration; + } + + public void setInteractionExpiration(double interactionExpiration) { + if (this.interactionExpiration != interactionExpiration) { + this.interactionExpiration = interactionExpiration; + notifyDataChanged(); + } + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationManager.java new file mode 100644 index 000000000..760d6b0a2 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationManager.java @@ -0,0 +1,964 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.conversation; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.apptentive.android.sdk.Apptentive; +import com.apptentive.android.sdk.Apptentive.LoginCallback; +import com.apptentive.android.sdk.ApptentiveInternal; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.comm.ApptentiveHttpClient; +import com.apptentive.android.sdk.conversation.ConversationMetadata.Filter; +import com.apptentive.android.sdk.migration.Migrator; +import com.apptentive.android.sdk.model.ConversationItem; +import com.apptentive.android.sdk.model.ConversationTokenRequest; +import com.apptentive.android.sdk.module.engagement.EngagementModule; +import com.apptentive.android.sdk.network.HttpJsonRequest; +import com.apptentive.android.sdk.network.HttpRequest; +import com.apptentive.android.sdk.notifications.ApptentiveNotification; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationCenter; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationObserver; +import com.apptentive.android.sdk.serialization.ObjectSerialization; +import com.apptentive.android.sdk.storage.AppRelease; +import com.apptentive.android.sdk.storage.AppReleaseManager; +import com.apptentive.android.sdk.storage.Device; +import com.apptentive.android.sdk.storage.DeviceManager; +import com.apptentive.android.sdk.storage.Sdk; +import com.apptentive.android.sdk.storage.SdkManager; +import com.apptentive.android.sdk.storage.SerializerException; +import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.Jwt; +import com.apptentive.android.sdk.util.ObjectUtils; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.Util; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchTask; + +import org.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.util.List; + +import static com.apptentive.android.sdk.ApptentiveLog.Level.VERY_VERBOSE; +import static com.apptentive.android.sdk.ApptentiveLogTag.*; +import static com.apptentive.android.sdk.ApptentiveNotifications.*; +import static com.apptentive.android.sdk.conversation.ConversationState.*; +import static com.apptentive.android.sdk.debug.Assert.*; +import static com.apptentive.android.sdk.debug.Tester.*; +import static com.apptentive.android.sdk.debug.TesterEvent.*; +import static com.apptentive.android.sdk.util.StringUtils.isNullOrEmpty; + +/** + * Class responsible for managing conversations. + *

    + *   - Saving/Loading conversations from/to files.
    + *   - Switching conversations when users login/logout.
    + *   - Creating anonymous conversation.
    + * 
    + */ +public class ConversationManager { + + protected static final String CONVERSATION_METADATA_PATH = "conversation-v1.meta"; + private static final String TAG_FETCH_CONVERSATION_TOKEN_REQUEST = "fetch_conversation_token"; + + private final WeakReference contextRef; + + /** + * A basic directory for storing conversation-related data. + */ + private final File apptentiveConversationsStorageDir; + + /** + * Current state of conversation metadata. + */ + private ConversationMetadata conversationMetadata; + + private Conversation activeConversation; + + public ConversationManager(Context context, File apptentiveConversationsStorageDir) { + if (context == null) { + throw new IllegalArgumentException("Context is null"); + } + + this.contextRef = new WeakReference<>(context.getApplicationContext()); + this.apptentiveConversationsStorageDir = apptentiveConversationsStorageDir; + + ApptentiveNotificationCenter.defaultCenter() + .addObserver(NOTIFICATION_APP_ENTERED_FOREGROUND, new ApptentiveNotificationObserver() { + @Override + public void onReceiveNotification(ApptentiveNotification notification) { + assertMainThread(); + if (activeConversation != null && activeConversation.hasActiveState()) { + ApptentiveLog.v(CONVERSATION, "App entered foreground notification received. Trying to fetch interactions..."); + final Context context = getContext(); + if (context != null) { + activeConversation.fetchInteractions(context); + } else { + ApptentiveLog.w(CONVERSATION, "Can't fetch conversation interactions: context is lost"); + } + } + } + }); + } + + //region Conversations + + /** + * Attempts to load an active conversation. Returns false if active conversation is + * missing or cannot be loaded + */ + public boolean loadActiveConversation(Context context) { + if (context == null) { + throw new IllegalArgumentException("Context is null"); + } + + try { + assertMainThread(); + + // resolving metadata + ApptentiveLog.vv(CONVERSATION, "Resolving metadata..."); + conversationMetadata = resolveMetadata(); + if (ApptentiveLog.canLog(VERY_VERBOSE)) { + printMetadata(conversationMetadata, "Loaded Metadata"); + } + + // attempt to load existing conversation + ApptentiveLog.vv(CONVERSATION, "Loading active conversation..."); + activeConversation = loadActiveConversationGuarded(); + + if (activeConversation != null) { + activeConversation.startListeningForChanges(); + activeConversation.scheduleSaveConversationData(); + + dispatchDebugEvent(EVT_CONVERSATION_LOAD, + "successful", Boolean.TRUE, + "conversation_state", activeConversation.getState().toString(), + "conversation_identifier", activeConversation.getConversationId()); + + handleConversationStateChange(activeConversation); + return true; + } + + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while loading active conversation"); + } + + dispatchDebugEvent(EVT_CONVERSATION_LOAD, "successful", Boolean.FALSE); + return false; + } + + private Conversation loadActiveConversationGuarded() throws IOException, SerializerException { + // we're going to scan metadata in attempt to find existing conversations + ConversationMetadataItem item; + + // if the user was logged in previously - we should have an active conversation + item = conversationMetadata.findItem(LOGGED_IN); + if (item != null) { + ApptentiveLog.v(CONVERSATION, "Loading logged-in conversation..."); + return loadConversation(item); + } + + // if no users were logged in previously - we might have an anonymous conversation + item = conversationMetadata.findItem(ANONYMOUS); + if (item != null) { + ApptentiveLog.v(CONVERSATION, "Loading anonymous conversation..."); + return loadConversation(item); + } + + // check if we have a 'pending' anonymous conversation + item = conversationMetadata.findItem(ANONYMOUS_PENDING); + if (item != null) { + ApptentiveLog.v(CONVERSATION, "Loading anonymous pending conversation..."); + final Conversation conversation = loadConversation(item); + fetchConversationToken(conversation); + return conversation; + } + + // check if we have a 'legacy pending' conversation + item = conversationMetadata.findItem(LEGACY_PENDING); + if (item != null) { + ApptentiveLog.v(CONVERSATION, "Loading legacy pending conversation..."); + final Conversation conversation = loadConversation(item); + fetchLegacyConversation(conversation); + return conversation; + } + + // Check for only LOGGED_OUT Conversations + if (conversationMetadata.hasItems()) { + ApptentiveLog.v(CONVERSATION, "Can't load conversation: only 'logged-out' conversations available"); + return null; + } + + // No conversation exists: Create a new one + ApptentiveLog.v(CONVERSATION, "Can't load conversation: creating anonymous conversation..."); + File dataFile = new File(apptentiveConversationsStorageDir, "conversation-" + Util.generateRandomFilename()); + File messagesFile = new File(apptentiveConversationsStorageDir, "messages-" + Util.generateRandomFilename()); + Conversation anonymousConversation = new Conversation(dataFile, messagesFile); + + // If there is a Legacy Conversation, migrate it into the new Conversation object. + // Check whether migration is needed. + // No Conversations exist in the meta-data. + // Do we have a Legacy Conversation or not? + final SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + String legacyConversationToken = prefs.getString(Constants.PREF_KEY_CONVERSATION_TOKEN, null); + if (!isNullOrEmpty(legacyConversationToken)) { + String lastSeenVersionString = prefs.getString(Constants.PREF_KEY_LAST_SEEN_SDK_VERSION, null); + Apptentive.Version version4 = new Apptentive.Version(); + version4.setVersion("4.0.0"); + Apptentive.Version lastSeenVersion = new Apptentive.Version(); + lastSeenVersion.setVersion(lastSeenVersionString); + if (lastSeenVersionString != null && lastSeenVersion.compareTo(version4) < 0) { + + anonymousConversation.setState(LEGACY_PENDING); + anonymousConversation.setConversationToken(legacyConversationToken); + + Migrator migrator = new Migrator(getContext(), prefs, anonymousConversation); + migrator.migrate(); + + ApptentiveLog.v("Fetching legacy conversation..."); + fetchLegacyConversation(anonymousConversation) + // remove legacy key when request is finished + .addListener(new HttpRequest.Adapter() { + @Override + public void onFinish(HttpRequest request) { + prefs.edit() + .remove(Constants.PREF_KEY_CONVERSATION_TOKEN) + .apply(); + } + }); + return anonymousConversation; + } + } + + // If there is no Legacy Conversation, then just connect it to the server. + anonymousConversation.setState(ANONYMOUS_PENDING); + fetchConversationToken(anonymousConversation); + return anonymousConversation; + } + + private HttpRequest fetchLegacyConversation(final Conversation conversation) { + assertNotNull(conversation); + if (conversation == null) { + throw new IllegalArgumentException("Conversation is null"); + } + + assertEquals(conversation.getState(), ConversationState.LEGACY_PENDING); + + final String conversationToken = conversation.getConversationToken(); + if (isNullOrEmpty(conversationToken)) { + throw new IllegalStateException("Missing conversation token"); + } + + assertFalse(isNullOrEmpty(conversationToken)); + if (isNullOrEmpty(conversationToken)) { + throw new IllegalArgumentException("Conversation is null"); + } + + HttpRequest request = getHttpClient() + .createLegacyConversationIdRequest(conversationToken, new HttpRequest.Listener() { + @Override + public void onFinish(HttpJsonRequest request) { + assertMainThread(); + + try { + JSONObject root = request.getResponseObject(); + String conversationId = root.getString("conversation_id"); + ApptentiveLog.d(CONVERSATION, "Conversation id: %s", conversationId); + + if (isNullOrEmpty(conversationId)) { + ApptentiveLog.e(CONVERSATION, "Can't fetch legacy conversation: missing 'id'"); + return; + } + + String conversationJWT = root.getString("anonymous_jwt_token"); + if (isNullOrEmpty(conversationId)) { + ApptentiveLog.e(CONVERSATION, "Can't fetch legacy conversation: missing 'anonymous_jwt_token'"); + return; + } + + ApptentiveLog.d(CONVERSATION, "Conversation JWT: %s", conversationJWT); + + // set conversation data + conversation.setState(ANONYMOUS); + conversation.setConversationToken(conversationJWT); + conversation.setConversationId(conversationId); + + // handle state change + handleConversationStateChange(conversation); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while handling legacy conversation id"); + } + } + + @Override + public void onCancel(HttpJsonRequest request) { + } + + @Override + public void onFail(HttpJsonRequest request, String reason) { + ApptentiveLog.e("Failed to fetch legacy conversation id: %s", reason); + } + }); + + request.setCallbackQueue(DispatchQueue.mainQueue()); // we only deal with conversation on the main queue + request.setTag(TAG_FETCH_CONVERSATION_TOKEN_REQUEST); + request.start(); + return request; + } + + private Conversation loadConversation(ConversationMetadataItem item) throws SerializerException { + // TODO: use same serialization logic across the project + final Conversation conversation = new Conversation(item.dataFile, item.messagesFile); + conversation.setEncryptionKey(item.getEncryptionKey()); // it's important to set encryption key before loading data + conversation.setState(item.getState()); // set the state same as the item's state + conversation.setUserId(item.getUserId()); + conversation.setConversationToken(item.getConversationToken()); // FIXME: this would be overwritten by the next call + conversation.loadConversationData(); + conversation.checkInternalConsistency(); + + return conversation; + } + + //endregion + + //region Conversation Token Fetching + + /** + * Starts fetching conversation. Returns immediately if conversation is already fetching. + * + * @return a new http-request object if conversation is not currently fetched or an instance of + * the existing request + */ + private HttpRequest fetchConversationToken(final Conversation conversation) { + // check if context is lost + final Context context = getContext(); + if (context == null) { + ApptentiveLog.w(CONVERSATION, "Unable to fetch conversation token: context reference is lost"); + return null; + } + + // check for an existing request + HttpRequest existingRequest = getHttpClient().findRequest(TAG_FETCH_CONVERSATION_TOKEN_REQUEST); + if (existingRequest != null) { + ApptentiveLog.d(CONVERSATION, "Conversation already fetching"); + return existingRequest; + } + + ApptentiveLog.i(CONVERSATION, "Fetching Configuration token task started."); + dispatchDebugEvent(EVT_CONVERSATION_WILL_FETCH_TOKEN); + + // Try to fetch a new one from the server. + ConversationTokenRequest conversationTokenRequest = new ConversationTokenRequest(); + + // Send the Device and Sdk now, so they are available on the server from the start. + final Device device = DeviceManager.generateNewDevice(context); + final Sdk sdk = SdkManager.generateCurrentSdk(); + final AppRelease appRelease = ApptentiveInternal.getInstance().getAppRelease(); + + conversationTokenRequest.setDevice(DeviceManager.getDiffPayload(null, device)); + conversationTokenRequest.setSdkAndAppRelease(SdkManager.getPayload(sdk), AppReleaseManager.getPayload(appRelease)); + + HttpRequest request = getHttpClient() + .createConversationTokenRequest(conversationTokenRequest, new HttpRequest.Listener() { + @Override + public void onFinish(HttpJsonRequest request) { + assertMainThread(); + + try { + JSONObject root = request.getResponseObject(); + String conversationToken = root.getString("token"); + ApptentiveLog.d(CONVERSATION, "ConversationToken: " + conversationToken); + String conversationId = root.getString("id"); + ApptentiveLog.d(CONVERSATION, "New Conversation id: %s", conversationId); + + if (isNullOrEmpty(conversationToken)) { + ApptentiveLog.e(CONVERSATION, "Can't fetch conversation: missing 'token'"); + dispatchDebugEvent(EVT_CONVERSATION_DID_FETCH_TOKEN, false); + return; + } + + if (isNullOrEmpty(conversationId)) { + ApptentiveLog.e(CONVERSATION, "Can't fetch conversation: missing 'id'"); + dispatchDebugEvent(EVT_CONVERSATION_DID_FETCH_TOKEN, false); + return; + } + + // set conversation data + conversation.setState(ANONYMOUS); + conversation.setConversationToken(conversationToken); + conversation.setConversationId(conversationId); + conversation.setDevice(device); + conversation.setLastSentDevice(device.clone()); + conversation.setAppRelease(appRelease); + conversation.setSdk(sdk); + conversation.setLastSeenSdkVersion(sdk.getVersion()); + + String personId = root.getString("person_id"); + ApptentiveLog.d(CONVERSATION, "PersonId: " + personId); + conversation.getPerson().setId(personId); + + dispatchDebugEvent(EVT_CONVERSATION_DID_FETCH_TOKEN, true); + + handleConversationStateChange(conversation); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while handling conversation token"); + dispatchDebugEvent(EVT_CONVERSATION_DID_FETCH_TOKEN, false); + } + } + + @Override + public void onCancel(HttpJsonRequest request) { + dispatchDebugEvent(EVT_CONVERSATION_DID_FETCH_TOKEN, false); + } + + @Override + public void onFail(HttpJsonRequest request, String reason) { + ApptentiveLog.w("Failed to fetch conversation token: %s", reason); + dispatchDebugEvent(EVT_CONVERSATION_DID_FETCH_TOKEN, false); + } + }); + + request.setCallbackQueue(DispatchQueue.mainQueue()); // we only deal with conversation on the main queue + request.setTag(TAG_FETCH_CONVERSATION_TOKEN_REQUEST); + request.start(); + return request; + } + + //endregion + + //region Conversation fetching + + private void handleConversationStateChange(Conversation conversation) { + assertMainThread(); + assertTrue(conversation != null && !conversation.hasState(UNDEFINED)); + + if (conversation != null && !conversation.hasState(UNDEFINED)) { + dispatchDebugEvent(EVT_CONVERSATION_STATE_CHANGE, + "conversation_state", conversation.getState().toString(), + "conversation_identifier", conversation.getConversationId()); + + ApptentiveNotificationCenter.defaultCenter() + .postNotification(NOTIFICATION_CONVERSATION_STATE_DID_CHANGE, + ObjectUtils.toMap(NOTIFICATION_KEY_CONVERSATION, conversation)); + + if (conversation.hasActiveState()) { + conversation.fetchInteractions(getContext()); + conversation.getMessageManager().startPollingMessages(); + + // Update conversation with push configuration changes that happened while it wasn't active. + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + int pushProvider = prefs.getInt(Constants.PREF_KEY_PUSH_PROVIDER, -1); + String pushToken = prefs.getString(Constants.PREF_KEY_PUSH_TOKEN, null); + if (pushProvider != -1 && pushToken != null) { + conversation.setPushIntegration(pushProvider, pushToken); + } + } + + updateMetadataItems(conversation); + if (ApptentiveLog.canLog(VERY_VERBOSE)) { + printMetadata(conversationMetadata, "Updated Metadata"); + } + } + } + + private void updateMetadataItems(Conversation conversation) { + ApptentiveLog.vv("Updating metadata: state=%s localId=%s conversationId=%s token=%s", + conversation.getState(), + conversation.getLocalIdentifier(), + conversation.getConversationId(), + conversation.getConversationToken()); + + // if the conversation is 'logged-in' we should not have any other 'logged-in' items in metadata + if (conversation.hasState(LOGGED_IN)) { + for (ConversationMetadataItem item : conversationMetadata) { + if (item.state.equals(LOGGED_IN)) { + item.state = LOGGED_OUT; + } + } + } + + // delete sensitive information + for (ConversationMetadataItem item : conversationMetadata) { + item.encryptionKey = null; + item.conversationToken = null; + } + + // update the state of the corresponding item + ConversationMetadataItem item = conversationMetadata.findItem(conversation); + if (item == null) { + item = new ConversationMetadataItem(conversation.getLocalIdentifier(), conversation.getConversationId(), conversation.getConversationDataFile(), conversation.getConversationMessagesFile()); + conversationMetadata.addItem(item); + } else { + assertTrue(conversation.getConversationId() != null || conversation.hasState(ANONYMOUS_PENDING), "Missing conversation id for state: %s", conversation.getState()); + item.conversationId = conversation.getConversationId(); + } + + item.state = conversation.getState(); + if (conversation.hasActiveState()) { + item.conversationToken = notNull(conversation.getConversationToken()); + } + + // update encryption key (if necessary) + if (conversation.hasState(LOGGED_IN)) { + item.encryptionKey = notNull(conversation.getEncryptionKey()); + item.userId = notNull(conversation.getUserId()); + } + + // apply changes + saveMetadata(); + } + + //endregion + + //region Metadata + + private ConversationMetadata resolveMetadata() { + try { + File metaFile = new File(apptentiveConversationsStorageDir, CONVERSATION_METADATA_PATH); + if (metaFile.exists()) { + ApptentiveLog.v(CONVERSATION, "Loading meta file: " + metaFile); + final ConversationMetadata metadata = ObjectSerialization.deserialize(metaFile, ConversationMetadata.class); + dispatchDebugEvent(EVT_CONVERSATION_METADATA_LOAD, true); + return metadata; + } else { + ApptentiveLog.v(CONVERSATION, "Meta file does not exist: " + metaFile); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while loading conversation metadata"); + } + + dispatchDebugEvent(EVT_CONVERSATION_METADATA_LOAD, false); + return new ConversationMetadata(); + } + + private void saveMetadata() { + try { + if (ApptentiveLog.canLog(VERY_VERBOSE)) { + ApptentiveLog.vv(CONVERSATION, "Saving metadata: ", conversationMetadata.toString()); + } + long start = System.currentTimeMillis(); + File metaFile = new File(apptentiveConversationsStorageDir, CONVERSATION_METADATA_PATH); + ObjectSerialization.serialize(metaFile, conversationMetadata); + ApptentiveLog.v(CONVERSATION, "Saved metadata (took %d ms)", System.currentTimeMillis() - start); + } catch (Exception e) { + ApptentiveLog.e(CONVERSATION, "Exception while saving metadata"); + } + } + + //endregion + + //region Login/Logout + + private static final LoginCallback NULL_LOGIN_CALLBACK = new LoginCallback() { + @Override + public void onLoginFinish() { + } + + @Override + public void onLoginFail(String errorMessage) { + } + }; + + public void login(final String token, final LoginCallback callback) { + // we only deal with an active conversation on the main thread + if (DispatchQueue.isMainQueue()) { + requestLoggedInConversation(token, callback != null ? callback : NULL_LOGIN_CALLBACK); // avoid constant null-pointer checking + } else { + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + requestLoggedInConversation(token, callback != null ? callback : NULL_LOGIN_CALLBACK); // avoid constant null-pointer checking + } + }); + } + } + + private void requestLoggedInConversation(final String token, final LoginCallback callback) { + assertMainThread(); + + if (callback == null) { + throw new IllegalArgumentException("Callback is null"); + } + + final String userId; + try { + final Jwt jwt = Jwt.decode(token); + userId = jwt.getPayload().optString("sub"); + if (StringUtils.isNullOrEmpty(userId)) { + ApptentiveLog.e("Error while extracting user id: Missing field \"sub\""); + callback.onLoginFail("Error while extracting user id: Missing field \"sub\""); + return; + } + + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while extracting user id"); + callback.onLoginFail("Exception while extracting user id"); + return; + } + + assertMainThread(); + + // Check if there is an active conversation + if (activeConversation == null) { + ApptentiveLog.d(CONVERSATION, "No active conversation. Performing login..."); + + // attempt to find previous logged out conversation + final ConversationMetadataItem conversationItem = conversationMetadata.findItem(new Filter() { + @Override + public boolean accept(ConversationMetadataItem item) { + return StringUtils.equal(item.getUserId(), userId); + } + }); + + if (conversationItem == null) { + ApptentiveLog.w("No conversation found matching user: '%s'. Logging in as new user.", userId); + sendFirstLoginRequest(userId, token, callback); + return; + } + + sendLoginRequest(conversationItem.conversationId, userId, token, callback); + return; + } + + switch (activeConversation.getState()) { + case ANONYMOUS_PENDING: + case LEGACY_PENDING: { + // start fetching conversation token (if not yet fetched) + final HttpRequest fetchRequest = activeConversation.hasState(ANONYMOUS_PENDING) ? + fetchConversationToken(activeConversation) : + fetchLegacyConversation(activeConversation); + if (fetchRequest == null) { + ApptentiveLog.e(CONVERSATION, "Unable to login: fetch request failed to send"); + callback.onLoginFail("fetch request failed to send"); + return; + } + + // attach a listener to an active request + fetchRequest.addListener(new HttpRequest.Listener() { + @Override + public void onFinish(HttpRequest request) { + assertMainThread(); + assertTrue(activeConversation != null && activeConversation.hasState(ANONYMOUS), "Active conversation is missing or in a wrong state: %s", activeConversation); + + if (activeConversation != null && activeConversation.hasState(ANONYMOUS)) { + ApptentiveLog.d(CONVERSATION, "Conversation fetching complete. Performing login..."); + sendLoginRequest(activeConversation.getConversationId(), userId, token, callback); + } else { + callback.onLoginFail("Conversation fetching completed abnormally"); + } + } + + @Override + public void onCancel(HttpRequest request) { + ApptentiveLog.d(CONVERSATION, "Unable to login: conversation fetching cancelled."); + callback.onLoginFail("Conversation fetching was cancelled"); + } + + @Override + public void onFail(HttpRequest request, String reason) { + ApptentiveLog.d(CONVERSATION, "Unable to login: conversation fetching failed."); + callback.onLoginFail("Conversation fetching failed: " + reason); + } + }); + } + break; + case ANONYMOUS: + sendLoginRequest(activeConversation.getConversationId(), userId, token, callback); + break; + case LOGGED_IN: + if (StringUtils.equal(activeConversation.getUserId(), userId)) { + ApptentiveLog.w("Already logged in as \"%s\"", userId); + callback.onLoginFinish(); + return; + } + // FIXME: If they are attempting to login to a different conversation, we need to gracefully end the active conversation here and kick off a login request to the desired conversation. + callback.onLoginFail("Already logged in. You must log out first."); + break; + default: + assertFail("Unexpected conversation state: " + activeConversation.getState()); + callback.onLoginFail("internal error"); + break; + } + } + + private void sendLoginRequest(String conversationId, final String userId, final String token, final LoginCallback callback) { + HttpJsonRequest request = getHttpClient().createLoginRequest(conversationId, token, new HttpRequest.Listener() { + @Override + public void onFinish(HttpJsonRequest request) { + try { + final JSONObject responseObject = request.getResponseObject(); + final String encryptionKey = responseObject.getString("encryption_key"); + final String incomingConversationId = responseObject.getString("id"); + handleLoginFinished(incomingConversationId, userId, token, encryptionKey); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while parsing login response"); + handleLoginFailed("Internal error"); + } + } + + @Override + public void onCancel(HttpJsonRequest request) { + handleLoginFailed("Login request was cancelled"); + } + + @Override + public void onFail(HttpJsonRequest request, String reason) { + handleLoginFailed(reason); + } + + private void handleLoginFinished(final String conversationId, final String userId, final String token, final String encryptionKey) { + assertFalse(isNullOrEmpty(encryptionKey),"Login finished with missing encryption key."); + assertFalse(isNullOrEmpty(token), "Login finished with missing token."); + assertMainThread(); + + try { + // if we were previously logged out we might end up with no active conversation + if (activeConversation == null) { + // attempt to find previous logged out conversation + final ConversationMetadataItem conversationItem = conversationMetadata.findItem(new Filter() { + @Override + public boolean accept(ConversationMetadataItem item) { + return StringUtils.equal(item.getUserId(), userId); + } + }); + + if (conversationItem != null) { + conversationItem.conversationToken = token; + conversationItem.encryptionKey = encryptionKey; + activeConversation = loadConversation(conversationItem); + } else { + ApptentiveLog.v(CONVERSATION, "Creating new logged in conversation..."); + File dataFile = new File(apptentiveConversationsStorageDir, "conversation-" + Util.generateRandomFilename()); + File messagesFile = new File(apptentiveConversationsStorageDir, "messages-" + Util.generateRandomFilename()); + activeConversation = new Conversation(dataFile, messagesFile); + + // FIXME: if we don't set these here - device payload would return 4xx error code + activeConversation.setDevice(DeviceManager.generateNewDevice(getContext())); + activeConversation.setAppRelease(ApptentiveInternal.getInstance().getAppRelease()); + activeConversation.setSdk(SdkManager.generateCurrentSdk()); + } + } + + activeConversation.setEncryptionKey(encryptionKey); + activeConversation.setConversationToken(token); + activeConversation.setConversationId(conversationId); + activeConversation.setUserId(userId); + activeConversation.setState(LOGGED_IN); + + activeConversation.startListeningForChanges(); + activeConversation.scheduleSaveConversationData(); + + handleConversationStateChange(activeConversation); + + // notify delegate + callback.onLoginFinish(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while creating logged-in conversation"); + handleLoginFailed("Internal error"); + } + } + + private void handleLoginFailed(String reason) { + callback.onLoginFail(reason); + } + }); + request.setCallbackQueue(DispatchQueue.mainQueue()); + request.start(); + } + + private void sendFirstLoginRequest(final String userId, final String token, final LoginCallback callback) { + final AppRelease appRelease = ApptentiveInternal.getInstance().getAppRelease(); + final Sdk sdk = SdkManager.generateCurrentSdk(); + final Device device = DeviceManager.generateNewDevice(getContext()); + + HttpJsonRequest request = getHttpClient().createFirstLoginRequest(token, appRelease, sdk, device, new HttpRequest.Listener() { + @Override + public void onFinish(HttpJsonRequest request) { + try { + final JSONObject responseObject = request.getResponseObject(); + final String encryptionKey = responseObject.getString("encryption_key"); + final String incomingConversationId = responseObject.getString("id"); + handleLoginFinished(incomingConversationId, userId, token, encryptionKey); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while parsing login response"); + handleLoginFailed("Internal error"); + } + } + + @Override + public void onCancel(HttpJsonRequest request) { + handleLoginFailed("Login request was cancelled"); + } + + @Override + public void onFail(HttpJsonRequest request, String reason) { + handleLoginFailed(reason); + } + + private void handleLoginFinished(final String conversationId, final String userId, final String token, final String encryptionKey) { + assertNull(activeConversation, "Finished logging into new conversation, but one was already active."); + assertFalse(isNullOrEmpty(encryptionKey),"Login finished with missing encryption key."); + assertFalse(isNullOrEmpty(token), "Login finished with missing token."); + assertMainThread(); + + try { + // attempt to find previous logged out conversation + final ConversationMetadataItem conversationItem = conversationMetadata.findItem(new Filter() { + @Override + public boolean accept(ConversationMetadataItem item) { + return StringUtils.equal(item.getUserId(), userId); + } + }); + + if (conversationItem != null) { + conversationItem.conversationToken = token; + conversationItem.encryptionKey = encryptionKey; + activeConversation = loadConversation(conversationItem); + } else { + ApptentiveLog.v(CONVERSATION, "Creating new logged in conversation..."); + File dataFile = new File(apptentiveConversationsStorageDir, "conversation-" + Util.generateRandomFilename()); + File messagesFile = new File(apptentiveConversationsStorageDir, "messages-" + Util.generateRandomFilename()); + activeConversation = new Conversation(dataFile, messagesFile); + + activeConversation.setAppRelease(appRelease); + activeConversation.setSdk(sdk); + activeConversation.setDevice(device); + } + + activeConversation.setEncryptionKey(encryptionKey); + activeConversation.setConversationToken(token); + activeConversation.setConversationId(conversationId); + activeConversation.setUserId(userId); + activeConversation.setState(LOGGED_IN); + + activeConversation.startListeningForChanges(); + activeConversation.scheduleSaveConversationData(); + + handleConversationStateChange(activeConversation); + + // notify delegate + callback.onLoginFinish(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while creating logged-in conversation"); + handleLoginFailed("Internal error"); + } + } + + private void handleLoginFailed(String reason) { + callback.onLoginFail(reason); + } + }); + request.setCallbackQueue(DispatchQueue.mainQueue()); + request.start(); + } + + public void logout() { + // we only deal with an active conversation on the main thread + if (!DispatchQueue.isMainQueue()) { + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + doLogout(); + } + }); + } else { + doLogout(); + } + } + + private void doLogout() { + assertMainThread(); + if (activeConversation != null) { + switch (activeConversation.getState()) { + case LOGGED_IN: + ApptentiveLog.d("Ending active conversation."); + EngagementModule.engageInternal(getContext(), activeConversation, "logout"); + // Post synchronously to ensure logout payload can be sent before destroying the logged in conversation. + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_CONVERSATION_WILL_LOGOUT, ObjectUtils.toMap(NOTIFICATION_KEY_CONVERSATION, activeConversation)); + activeConversation.destroy(); + activeConversation.setState(LOGGED_OUT); + handleConversationStateChange(activeConversation); + activeConversation = null; + ApptentiveInternal.dismissAllInteractions(); + break; + default: + ApptentiveLog.w(CONVERSATION, "Attempted to logout() from Conversation, but the Active Conversation was not in LOGGED_IN state."); + break; + } + } else { + ApptentiveLog.w(CONVERSATION, "Attempted to logout(), but there was no Active Conversation."); + } + dispatchDebugEvent(EVT_LOGOUT); + } + + //endregion + + //region Debug + + private void printMetadata(ConversationMetadata metadata, String title) { + List items = metadata.getItems(); + if (items.isEmpty()) { + ApptentiveLog.vv(CONVERSATION, "%s (%d item(s))", title, items.size()); + return; + } + + Object[][] rows = new Object[1 + items.size()][]; + rows[0] = new Object[] { + "state", + "localId", + "conversationId", + "userId", + "dataFile", + "messagesFile", + "conversationToken", + "encryptionKey" + }; + int index = 1; + for (ConversationMetadataItem item : items) { + rows[index++] = new Object[] { + item.state, + item.localConversationId, + item.conversationId, + item.userId, + item.dataFile, + item.messagesFile, + item.conversationToken, + item.encryptionKey + }; + } + + ApptentiveLog.vv(CONVERSATION, "%s (%d item(s))\n%s", title, items.size(), StringUtils.table(rows)); + } + + //endregion + + //region Getters/Setters + + public Conversation getActiveConversation() { + // assertMainThread(); TODO: we should still only access the conversation on a dedicated queue + // but at this time we can't do it by design + return activeConversation; + } + + public ConversationMetadata getConversationMetadata() { + return conversationMetadata; + } + + private ApptentiveHttpClient getHttpClient() { + return ApptentiveInternal.getInstance().getApptentiveHttpClient(); // TODO: remove coupling + } + + private Context getContext() { + return contextRef.get(); + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadata.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadata.java new file mode 100644 index 000000000..cd87f6ee1 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadata.java @@ -0,0 +1,122 @@ +package com.apptentive.android.sdk.conversation; + +import com.apptentive.android.sdk.serialization.SerializableObject; +import com.apptentive.android.sdk.util.StringUtils; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Class which represents all conversation entries stored on the disk + */ +public class ConversationMetadata implements SerializableObject, Iterable { + private static final byte VERSION = 1; + + private final List items; + + public ConversationMetadata() { + items = new ArrayList<>(); + } + + //region Serialization + + public ConversationMetadata(DataInput in) throws IOException { + byte version = in.readByte(); + if (version != VERSION) { + throw new IOException("Expected version " + VERSION + " but was " + version); + } + + int count = in.readByte(); + items = new ArrayList<>(count); + for (int i = 0; i < count; ++i) { + items.add(new ConversationMetadataItem(in)); + } + } + + @Override + public void writeExternal(DataOutput out) throws IOException { + out.writeByte(VERSION); + out.write(items.size()); + for (int i = 0; i < items.size(); ++i) { + items.get(i).writeExternal(out); + } + } + + //endregion + + //region Items + + void addItem(ConversationMetadataItem item) { + items.add(item); + } + + ConversationMetadataItem findItem(final ConversationState state) { + return findItem(new Filter() { + @Override + public boolean accept(ConversationMetadataItem item) { + return state.equals(item.state); + } + }); + } + + ConversationMetadataItem findItem(final Conversation conversation) { + return findItem(new Filter() { + @Override + public boolean accept(ConversationMetadataItem item) { + return StringUtils.equal(item.localConversationId, conversation.getLocalIdentifier()); + } + }); + } + + ConversationMetadataItem findItem(Filter filter) { + for (ConversationMetadataItem item : items) { + if (filter.accept(item)) { + return item; + } + } + return null; + } + + //endregion + + //region Iterable + + @Override + public Iterator iterator() { + return items.iterator(); + } + + //endregion + + //region Getters/Setters + + public boolean hasItems() { + return items.size() > 0; + } + + public List getItems() { + return items; + } + + //endregion + + //region Filter + + public interface Filter { + boolean accept(ConversationMetadataItem item); + } + + //endregion + + + @Override + public String toString() { + return "ConversationMetadata{" + + "items=" + items + + '}'; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadataItem.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadataItem.java new file mode 100644 index 000000000..d15888a8b --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadataItem.java @@ -0,0 +1,138 @@ +package com.apptentive.android.sdk.conversation; + +import com.apptentive.android.sdk.serialization.SerializableObject; +import com.apptentive.android.sdk.util.StringUtils; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.File; +import java.io.IOException; + +import static com.apptentive.android.sdk.util.Util.readNullableUTF; +import static com.apptentive.android.sdk.util.Util.writeNullableUTF; + +/** + * A light weight representation of the conversation object stored on the disk. + */ +public class ConversationMetadataItem implements SerializableObject { + + /** + * The state of the target conversation + */ + ConversationState state = ConversationState.UNDEFINED; + + /** + * Local conversation ID + */ + final String localConversationId; + + /** + * Conversation ID which was received from the backend + */ + String conversationId; + + /** + * The token for active conversations + */ + String conversationToken; + + /** + * Storage filename for conversation serialized data + */ + final File dataFile; + + /** + * Storage filename for conversation serialized messages + */ + final File messagesFile; + + /** + * Key for encrypting payloads + */ + String encryptionKey; + + /** + * An optional user ID for logged in conversations + */ + String userId; + + public ConversationMetadataItem(String localConversationId, String conversationId, File dataFile, File messagesFile) { + if (localConversationId == null) { + throw new IllegalArgumentException("Local conversation id is null"); + } + + if (dataFile == null) { + throw new IllegalArgumentException("Data file is null"); + } + + if (messagesFile == null) { + throw new IllegalArgumentException("Messages file is null"); + } + + this.localConversationId = localConversationId; + this.conversationId = conversationId; + this.dataFile = dataFile; + this.messagesFile = messagesFile; + } + + public ConversationMetadataItem(DataInput in) throws IOException { + localConversationId = in.readUTF(); + conversationId = readNullableUTF(in); + conversationToken = readNullableUTF(in); + dataFile = new File(in.readUTF()); + messagesFile = new File(in.readUTF()); + state = ConversationState.valueOf(in.readByte()); + encryptionKey = readNullableUTF(in); + userId = readNullableUTF(in); + } + + @Override + public void writeExternal(DataOutput out) throws IOException { + out.writeUTF(localConversationId); + writeNullableUTF(out, conversationId); + writeNullableUTF(out, conversationToken); + out.writeUTF(dataFile.getAbsolutePath()); + out.writeUTF(messagesFile.getAbsolutePath()); + out.writeByte(state.ordinal()); + writeNullableUTF(out, encryptionKey); + writeNullableUTF(out, userId); + } + + public String getLocalConversationId() { + return localConversationId; + } + + public String getConversationId() { + return conversationId; + } + + public ConversationState getState() { + return state; + } + + public String getEncryptionKey() { + return encryptionKey; + } + + public String getUserId() { + return userId; + } + + public String getConversationToken() { + return conversationToken; + } + + @Override + public String toString() { + return "ConversationMetadataItem{" + + "state=" + state + + ", localConversationId='" + localConversationId + '\'' + + ", conversationId='" + conversationId + '\'' + + ", conversationToken='" + conversationToken + '\'' + + ", dataFile=" + dataFile + + ", messagesFile=" + messagesFile + + ", encryptionKey='" + encryptionKey + '\'' + + ", userId='" + userId + '\'' + + '}'; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationState.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationState.java new file mode 100644 index 000000000..7253f621d --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationState.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.conversation; + +public enum ConversationState { + /** + * Conversation state is not known + */ + UNDEFINED, + + /** + * A Legacy Conversation has been migrated, but still has an old OAuth token. Waiting to get a new + * JWT and Conversation ID from the server. + */ + LEGACY_PENDING, + + /** + * No logged in user and no conversation token + */ + ANONYMOUS_PENDING, + + /** + * No logged in user with conversation token + */ + ANONYMOUS, + + /** + * The conversation belongs to the currently logged-in user + */ + LOGGED_IN, + + /** + * The conversation belongs to a logged-out user + */ + LOGGED_OUT; + + /** + * Returns the {@link ConversationState} object corresponding + * to value or UNDEFINED if value + * is out of range + */ + public static ConversationState valueOf(byte value) { + final ConversationState[] values = ConversationState.values(); + + if (value >= 0 && value < values.length) { + return values[value]; + } + + return UNDEFINED; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/FileMessageStore.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/FileMessageStore.java new file mode 100644 index 000000000..c9f542555 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/FileMessageStore.java @@ -0,0 +1,320 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.conversation; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; +import com.apptentive.android.sdk.serialization.SerializableObject; +import com.apptentive.android.sdk.storage.MessageStore; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.Util; + +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutput; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static com.apptentive.android.sdk.util.Util.readNullableBoolean; +import static com.apptentive.android.sdk.util.Util.readNullableDouble; +import static com.apptentive.android.sdk.util.Util.readNullableUTF; +import static com.apptentive.android.sdk.util.Util.writeNullableBoolean; +import static com.apptentive.android.sdk.util.Util.writeNullableDouble; +import static com.apptentive.android.sdk.util.Util.writeNullableUTF; + +class FileMessageStore implements MessageStore { + /** + * Binary format version + */ + private static final byte VERSION = 1; + + private final File file; + private final List messageEntries; + private boolean shouldFetchFromFile; + + FileMessageStore(File file) { + this.file = file; + this.messageEntries = new ArrayList<>(); // we need a random access + this.shouldFetchFromFile = true; // we would lazily read it from a file later + } + + //region MessageStore + + @Override + public synchronized void addOrUpdateMessages(ApptentiveMessage... apptentiveMessages) { + fetchEntries(); + + for (ApptentiveMessage apptentiveMessage : apptentiveMessages) { + MessageEntry existing = findMessageEntry(apptentiveMessage); + if (existing != null) { + // Update + existing.id = apptentiveMessage.getId(); + existing.state = apptentiveMessage.getState().name(); + if (apptentiveMessage.isRead()) { // A apptentiveMessage can't be unread after being read. + existing.isRead = true; + } + existing.json = apptentiveMessage.getJsonObject().toString(); + } else { + // Insert + MessageEntry entry = new MessageEntry(); + entry.id = apptentiveMessage.getId(); + entry.clientCreatedAt = apptentiveMessage.getClientCreatedAt(); + entry.nonce = apptentiveMessage.getNonce(); + entry.state = apptentiveMessage.getState().name(); + entry.isRead = apptentiveMessage.isRead(); + entry.json = apptentiveMessage.getJsonObject().toString(); + messageEntries.add(entry); + } + } + + writeToFile(); + } + + @Override + public synchronized void updateMessage(ApptentiveMessage apptentiveMessage) { + fetchEntries(); + + MessageEntry entry = findMessageEntry(apptentiveMessage); + if (entry != null) { + entry.id = apptentiveMessage.getId(); + entry.clientCreatedAt = apptentiveMessage.getClientCreatedAt(); + entry.nonce = apptentiveMessage.getNonce(); + entry.state = apptentiveMessage.getState().name(); + if (apptentiveMessage.isRead()) { // A apptentiveMessage can't be unread after being read. + entry.isRead = true; + } + entry.json = apptentiveMessage.getJsonObject().toString(); + writeToFile(); + } + } + + @Override + public synchronized List getAllMessages() throws Exception { + fetchEntries(); + + List apptentiveMessages = new ArrayList<>(); + for (MessageEntry entry : messageEntries) { + ApptentiveMessage apptentiveMessage = MessageFactory.fromJson(entry.json); + if (apptentiveMessage == null) { + ApptentiveLog.e("Error parsing Record json from database: %s", entry.json); + continue; + } + apptentiveMessage.setState(ApptentiveMessage.State.parse(entry.state)); + apptentiveMessage.setRead(entry.isRead); + apptentiveMessages.add(apptentiveMessage); + } + return apptentiveMessages; + } + + @Override + public synchronized String getLastReceivedMessageId() throws Exception { + fetchEntries(); + + final String savedState = ApptentiveMessage.State.saved.name(); + for (int i = messageEntries.size() - 1; i >= 0; --i) { + final MessageEntry entry = messageEntries.get(i); + if (StringUtils.equal(entry.state, savedState) && entry.id != null) { + return entry.id; + } + } + return null; + } + + @Override + public synchronized int getUnreadMessageCount() throws Exception { + fetchEntries(); + + int count = 0; + for (MessageEntry entry : messageEntries) { + if (!entry.isRead && entry.id != null) { + ++count; + } + } + return count; + } + + @Override + public synchronized void deleteAllMessages() { + messageEntries.clear(); + writeToFile(); + } + + @Override + public synchronized void deleteMessage(String nonce) { + fetchEntries(); + + for (int i = 0; i < messageEntries.size(); ++i) { + if (StringUtils.equal(nonce, messageEntries.get(i).nonce)) { + messageEntries.remove(i); + writeToFile(); + break; + } + } + } + + @Override + public ApptentiveMessage findMessage(String nonce) { + fetchEntries(); + + for (int i = 0; i < messageEntries.size(); ++i) { + final MessageEntry messageEntry = messageEntries.get(i); + if (StringUtils.equal(nonce, messageEntry.nonce)) { + return MessageFactory.fromJson(messageEntry.json); + } + } + + return null; + } + + //endregion + + //region File save/load + + private synchronized void fetchEntries() { + if (shouldFetchFromFile) { + readFromFile(); + shouldFetchFromFile = false; + } + } + + private synchronized void readFromFile() { + messageEntries.clear(); + try { + if (file.exists()) { + List entries = readFromFileGuarded(); + messageEntries.addAll(entries); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while reading entries"); + } + } + + private List readFromFileGuarded() throws IOException { + DataInputStream dis = null; + try { + dis = new DataInputStream(new FileInputStream(file)); + byte version = dis.readByte(); + if (version != VERSION) { + throw new IOException("Unsupported binary version: " + version); + } + int entryCount = dis.readInt(); + List entries = new ArrayList<>(); + for (int i = 0; i < entryCount; ++i) { + entries.add(new MessageEntry(dis)); + } + return entries; + } finally { + Util.ensureClosed(dis); + } + } + + private synchronized void writeToFile() { + try { + writeToFileGuarded(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while saving messages"); + } + shouldFetchFromFile = false; // mark it as not shouldFetchFromFile to keep a memory version + } + + private void writeToFileGuarded() throws IOException { + DataOutputStream dos = null; + try { + dos = new DataOutputStream(new FileOutputStream(file)); + dos.writeByte(VERSION); + dos.writeInt(messageEntries.size()); + for (MessageEntry entry : messageEntries) { + entry.writeExternal(dos); + } + } finally { + Util.ensureClosed(dos); + } + } + + //endregion + + //region Filtering + + private MessageEntry findMessageEntry(ApptentiveMessage message) { + Assert.assertNotNull(message); + return message != null ? findMessageEntry(message.getNonce()) : null; + } + + private MessageEntry findMessageEntry(String nonce) { + for (MessageEntry entry : messageEntries) { + if (StringUtils.equal(entry.nonce, nonce)) { + return entry; + } + } + return null; + } + + //endregion + + //region Message Entry + + private static class MessageEntry implements SerializableObject { + String id; + Double clientCreatedAt; + String nonce; + String state; + Boolean isRead; + String json; + + MessageEntry() { + } + + MessageEntry(DataInput in) throws IOException { + id = readNullableUTF(in); + clientCreatedAt = readNullableDouble(in); + nonce = readNullableUTF(in); + state = readNullableUTF(in); + isRead = readNullableBoolean(in); + json = readNullableUTF(in); + } + + @Override + public void writeExternal(DataOutput out) throws IOException { + writeNullableUTF(out, id); + writeNullableDouble(out, clientCreatedAt); + writeNullableUTF(out, nonce); + writeNullableUTF(out, state); + writeNullableBoolean(out, isRead); + writeNullableUTF(out, json); + } + + @Override + public String toString() { + return "MessageEntry{" + + "id='" + id + '\'' + + ", clientCreatedAt=" + clientCreatedAt + + ", nonce='" + nonce + '\'' + + ", state='" + state + '\'' + + ", isRead=" + isRead + + ", json='" + json + '\'' + + '}'; + } + } + + //endregion + + @Override + public String toString() { + return "FileMessageStore{" + + "file=" + file + + ", messageEntries=" + messageEntries + + ", shouldFetchFromFile=" + shouldFetchFromFile + + '}'; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/debug/Assert.java b/apptentive/src/main/java/com/apptentive/android/sdk/debug/Assert.java index edcb548b0..bc62d573c 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/debug/Assert.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/debug/Assert.java @@ -1,27 +1,9 @@ -// -// Assert.java -// -// Lunar Unity Mobile Console -// https://github.com/SpaceMadness/lunar-unity-console -// -// Copyright 2017 Alex Lementuev, SpaceMadness. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - package com.apptentive.android.sdk.debug; -import java.util.Collection; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.util.ObjectUtils; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.threading.DispatchQueue; /** * A set of assertion methods useful for writing 'runtime' tests. These methods can be used directly: @@ -34,152 +16,207 @@ */ public class Assert { + private static AssertImp imp = new AssertImp() { + @Override + public void assertFailed(String message) { + ApptentiveLog.e("Assertion failed: " + message + "\n" + getStackTrace(6)); + } + }; + + //region Booleans + /** * Asserts that condition is true */ public static void assertTrue(boolean condition) { - // FIXME: implement me + if (imp != null && !condition) { + imp.assertFailed("Expected 'true' but was 'false'"); + } } /** * Asserts that condition is true */ public static void assertTrue(boolean condition, String message) { - // FIXME: implement me + if (imp != null && !condition) { + imp.assertFailed(message); + } } /** * Asserts that condition is true */ public static void assertTrue(boolean condition, String format, Object... args) { - // FIXME: implement me + if (imp != null && !condition) { + imp.assertFailed(StringUtils.format(format, args)); + } } /** * Asserts that condition is false */ public static void assertFalse(boolean condition) { - // FIXME: implement me + if (imp != null && condition) { + imp.assertFailed("Expected 'false' but was 'true'"); + } } /** * Asserts that condition is false */ public static void assertFalse(boolean condition, String message) { - // FIXME: implement me + if (imp != null && condition) { + imp.assertFailed(message); + } } /** * Asserts that condition is false */ public static void assertFalse(boolean condition, String format, Object... args) { - // FIXME: implement me + if (imp != null && condition) { + imp.assertFailed(StringUtils.format(format, args)); + } } - /** - * Asserts that an object is null - */ - public static void assertNull(Object object) { - // FIXME: implement me - } + //endregion - /** - * Asserts that an object is null - */ - public static void assertNull(Object object, String message) { - // FIXME: implement me - } + //region Nullability /** - * Asserts that an object is null + * Helper function for getting non-null references */ - public static void assertNull(Object object, String format, Object... args) { - // FIXME: implement me + public static T notNull(T reference) { + assertNotNull(reference); + return reference; } /** - * Asserts that an object isn't null + * Asserts that an object isn't null */ public static void assertNotNull(Object object) { - // FIXME: implement me + if (imp != null && object == null) { + imp.assertFailed("Not-null expected"); + } } /** - * Asserts that an object isn't null + * Asserts that an object isn't null */ public static void assertNotNull(Object object, String message) { - // FIXME: implement me + if (imp != null && object == null) { + imp.assertFailed(message); + } } /** - * Asserts that an object isn't null + * Asserts that an object isn't null */ public static void assertNotNull(Object object, String format, Object... args) { - // FIXME: implement me - } - - /** Asserts that executed is not equal to actual */ - public static void assertNotEquals(int expected, int actual) { - // FIXME: implement me - } - - /** Asserts that executed is not equal to actual */ - public static void assertNotEquals(int expected, int actual, String message) { - // FIXME: implement me - } - - /** Asserts that executed is not equal to actual */ - public static void assertNotEquals(int expected, int actual, String format, Object... args) { - // FIXME: implement me + if (imp != null && object == null) { + imp.assertFailed(String.format(format, args)); + } } /** - * Asserts that collection isRegistered an object + * Asserts that an object is null */ - public static void assertContains(Collection collection, Object object) { - // FIXME: implement me + public static void assertNull(Object object) { + if (imp != null && object != null) { + imp.assertFailed(StringUtils.format("Expected 'null' but was '%s'", object)); + } } /** - * Asserts that collection isRegistered an object + * Asserts that an object is null */ - public static void assertContains(Collection collection, Object object, String message) { - // FIXME: implement me + public static void assertNull(Object object, String message) { + if (imp != null && object != null) { + imp.assertFailed(message); + } } /** - * Asserts that collection isRegistered an object + * Asserts that an object is null */ - public static void assertContains(Collection collection, Object object, String format, Object... args) { - // FIXME: implement me + public static void assertNull(Object object, String format, Object... args) { + if (imp != null && object != null) { + imp.assertFailed(String.format(format, args)); + } + } + + //endregion + + //region Equality + + public static void assertEquals(Object expected, Object actual) { + if (imp != null && !ObjectUtils.equal(expected, actual)) { + imp.assertFailed(StringUtils.format("Expected '%s' but was '%s'", expected, actual)); + } + } + + public static void assertNotEquals(Object first, Object second) { + if (imp != null && ObjectUtils.equal(first, second)) { + imp.assertFailed(StringUtils.format("Expected '%s' and '%s' to differ, but they are the same.", first, second)); + } } + //endregion + + //region Threading + /** - * Asserts that collection doesn't contain an object + * Asserts that code executes on the main thread. */ - public static void assertNotContains(Collection collection, Object object) { - // FIXME: implement me + public static void assertMainThread() { + if (imp != null && !DispatchQueue.isMainQueue()) { + imp.assertFailed(StringUtils.format("Expected 'main' thread but was '%s'", Thread.currentThread().getName())); + } } /** - * Asserts that collection doesn't contain an object + * Asserts that code executes on the main thread. */ - public static void assertNotContains(Collection collection, Object object, String message) { - // FIXME: implement me + public static void assertBackgroundThread() { + if (imp != null && DispatchQueue.isMainQueue()) { + imp.assertFailed("Expected background thread but was 'main'"); + } } + //endregion + + //region Failure + /** - * Asserts that collection doesn't contain an object + * General failure with a message */ - public static void assertNotContains(Collection collection, Object object, String format, Object... args) { - // FIXME: implement me + public static void assertFail(String message) { + if (imp != null) { + imp.assertFailed(message); + } } - /** Asserts that code is executed on the main thread */ - public static void assertMainThread() { + //endregion + + public static void setImp(AssertImp imp) { + Assert.imp = imp; } - /** Asserts that code is not executed on the main thread */ - public static void assertNotMainThread() { + private static String getStackTrace(int offset) { + StringBuilder sb = new StringBuilder(); + try { + StackTraceElement[] elements = Thread.currentThread().getStackTrace(); + + if (elements != null && elements.length > 0) { + for (int i = offset; i < elements.length; ++i) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(elements[i].toString()); + } + } + } catch (Exception ignored) { + } + return sb.toString(); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/debug/AssertImp.java b/apptentive/src/main/java/com/apptentive/android/sdk/debug/AssertImp.java new file mode 100644 index 000000000..630f3a473 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/debug/AssertImp.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.debug; + +public interface AssertImp { + void assertFailed(String message); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/debug/Tester.java b/apptentive/src/main/java/com/apptentive/android/sdk/debug/Tester.java new file mode 100644 index 000000000..d1c80d394 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/debug/Tester.java @@ -0,0 +1,91 @@ +package com.apptentive.android.sdk.debug; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.util.ObjectUtils; + +import java.util.Map; + +import static com.apptentive.android.sdk.debug.TesterEvent.*; + +public class Tester { + private static Tester instance; + + private TesterEventListener listener; + + private Tester(TesterEventListener listener) { + this.listener = listener; + } + + //////////////////////////////////////////////////////////////// + // Instance + + public static void init(TesterEventListener delegate) { + instance = new Tester(delegate); + } + + public static void destroy() { + instance = null; + } + + //////////////////////////////////////////////////////////////// + // Events + + public static boolean isListeningForDebugEvents() { + return instance != null && instance.listener != null; + } + + public static void dispatchDebugEvent(String name) { + if (isListeningForDebugEvents()) { + notifyEvent(name, null); + } + } + + public static void dispatchDebugEvent(String name, boolean successful) { + if (isListeningForDebugEvents()) { + notifyEvent(name, ObjectUtils.toMap(EVT_KEY_SUCCESSFUL, successful)); + } + } + + public static void dispatchDebugEvent(String name, String key, Object value) { + if (isListeningForDebugEvents()) { + notifyEvent(name, ObjectUtils.toMap(key, value)); + } + } + + public static void dispatchDebugEvent(String name, String key1, Object value1, String key2, Object value2) { + if (isListeningForDebugEvents()) { + notifyEvent(name, ObjectUtils.toMap(key1, value1, key2, value2)); + } + } + + public static void dispatchDebugEvent(String name, String key1, Object value1, String key2, Object value2, String key3, Object value3) { + if (isListeningForDebugEvents()) { + notifyEvent(name, ObjectUtils.toMap(key1, value1, key2, value2, key3, value3)); + } + } + + public static void dispatchException(Throwable e) { + if (isListeningForDebugEvents() && e != null) { + StringBuilder stackTrace = new StringBuilder(); + StackTraceElement[] elements = e.getStackTrace(); + for (int i = 0; i < elements.length; ++i) { + stackTrace.append(elements[i]); + if (i < elements.length - 1) { + stackTrace.append('\n'); + } + } + notifyEvent(EVT_EXCEPTION, ObjectUtils.toMap( + "class,", e.getClass().getName(), + "message", e.getMessage(), + "stacktrace", stackTrace.toString())); + } + } + + private static void notifyEvent(String name, Map userInfo) { + try { + instance.listener.onDebugEvent(name, userInfo); + } catch (Exception e) { + ApptentiveLog.e(e, "Error while dispatching debug event: %s", name); + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/debug/TesterEvent.java b/apptentive/src/main/java/com/apptentive/android/sdk/debug/TesterEvent.java new file mode 100644 index 000000000..fcf0f0431 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/debug/TesterEvent.java @@ -0,0 +1,39 @@ +package com.apptentive.android.sdk.debug; + +public class TesterEvent { + + /** An active conversation loading finished (might be either successful or failed) */ + public static final String EVT_CONVERSATION_LOAD = "conversation_load"; // { successful:boolean, conversation_state:String, conversation_identifier:String } + + /** An active conversation state changed */ + public static final String EVT_CONVERSATION_STATE_CHANGE = "conversation_state_change"; // { conversation_state:String, conversation_identifier:String } + + /** Conversation metadata loading finished (might be either successful or failed) */ + public static final String EVT_CONVERSATION_METADATA_LOAD = "conversation_metadata_load"; // { successful:boolean } + + /** Conversation token fetch request started */ + public static final String EVT_CONVERSATION_WILL_FETCH_TOKEN = "conversation_will_fetch_token"; + + /** Conversation token fetch request finished (might be either successful or failed) */ + public static final String EVT_CONVERSATION_DID_FETCH_TOKEN = "conversation_did_fetch_token"; // { successful:boolean } + + /** Conversation interactions fetch request finished (might be either successful or failed) */ + public static final String EVT_CONVERSATION_FETCH_INTERACTIONS = "conversation_fetch_interactions"; // { successful:boolean } + + /** There was an unexpected runtime exception */ + public static final String EVT_EXCEPTION = "exception"; // { class:String, message:String, stackTrace:String } + + /** Apptentive event was sent */ + public static final String EVT_APPTENTIVE_EVENT = "apptentive_event"; // { eventLabel:String } + public static final String EVT_APPTENTIVE_EVENT_KEY_EVENT_LABEL = "eventLabel"; + + /** The login attempt finished (might be either successful or failed) */ + public static final String EVT_LOGIN_FINISHED = "login_finished"; + public static final String EVT_LOGIN_FINISHED_ERROR_MESSAGE = "error_message"; + + /** The logout call finished */ + public static final String EVT_LOGOUT = "logout"; + + // Common event keys + public static final String EVT_KEY_SUCCESSFUL = "successful"; +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/debug/TesterEventListener.java b/apptentive/src/main/java/com/apptentive/android/sdk/debug/TesterEventListener.java new file mode 100644 index 000000000..13dc553aa --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/debug/TesterEventListener.java @@ -0,0 +1,8 @@ +package com.apptentive.android.sdk.debug; + +import java.util.Map; + +public interface TesterEventListener +{ + void onDebugEvent(String name, Map userInfo); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/Encryptor.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/Encryptor.java new file mode 100644 index 000000000..44a556b78 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/Encryptor.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.encryption; + +import com.apptentive.android.sdk.util.StringUtils; + +import java.io.UnsupportedEncodingException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.AlgorithmParameterSpec; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class Encryptor { + + private static final int IV_SIZE = 16; + + private SecretKeySpec key; + + /** + * Initializes the Encryptor + * @param hexKey A hex encoded String with the key data. + */ + public Encryptor(String hexKey) { + this.key = new SecretKeySpec(StringUtils.hexToBytes(hexKey), "AES"); + } + + Encryptor(byte[] keyBytes) { + this.key = new SecretKeySpec(keyBytes, "AES"); + } + + public byte[] encrypt(byte[] plainText) throws UnsupportedEncodingException, + NoSuchPaddingException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException, + InvalidKeyException { + byte[] iv = new byte[IV_SIZE]; + new SecureRandom().nextBytes(iv); + byte[] cipherText = encrypt(iv, plainText); + byte[] ret = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, ret, 0, iv.length); + System.arraycopy(cipherText, 0, ret, iv.length, cipherText.length); + return ret; + } + + private byte[] encrypt(byte[] iv, byte[] plainText) throws NoSuchAlgorithmException, + NoSuchPaddingException, + InvalidAlgorithmParameterException, + InvalidKeyException, + BadPaddingException, + IllegalBlockSizeException { + + AlgorithmParameterSpec ivParameterSpec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.ENCRYPT_MODE, key, ivParameterSpec); + return cipher.doFinal(plainText); + } + + private byte[] decrypt(byte[] iv, byte[] cipherText) throws NoSuchPaddingException, + NoSuchAlgorithmException, + BadPaddingException, + IllegalBlockSizeException, + InvalidAlgorithmParameterException, + InvalidKeyException { + AlgorithmParameterSpec ivParameterSpec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); + cipher.init(Cipher.DECRYPT_MODE, key, ivParameterSpec); + return cipher.doFinal(cipherText); + } + + public byte[] decrypt(byte[] ivAndCipherText) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + byte[] iv = new byte[IV_SIZE]; + byte[] cipherText = new byte[ivAndCipherText.length - IV_SIZE]; + System.arraycopy(ivAndCipherText, 0, iv, 0, IV_SIZE); + System.arraycopy(ivAndCipherText, IV_SIZE, cipherText, 0, ivAndCipherText.length - IV_SIZE); + return decrypt(iv, cipherText); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/lifecycle/ApptentiveActivityLifecycleCallbacks.java b/apptentive/src/main/java/com/apptentive/android/sdk/lifecycle/ApptentiveActivityLifecycleCallbacks.java index f458e0b34..f89d1bdad 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/lifecycle/ApptentiveActivityLifecycleCallbacks.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/lifecycle/ApptentiveActivityLifecycleCallbacks.java @@ -17,11 +17,22 @@ import java.util.concurrent.atomic.AtomicInteger; +/** + * 1. Keeps track of whether the app is in the foreground. It does this by counting the number of active Activities. + * 2 Tells the SDK when the app goes to the background (exits), or comes to the foreground (launches). + * 3. Tells the SDK when an Activity starts or resumes, so the SDK can hold a weak reference to the top Activity. + */ public class ApptentiveActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks { private AtomicInteger foregroundActivities = new AtomicInteger(0); + /** + * A Runnable that is postDelayed() in onActivityStopped() if the foregroundActivities is 0. When it runs, if the count is still 0, it fires appEnteredBackground() + */ private Runnable checkFgBgRoutine; + /** + * Set to false when the app goes to the background. + */ private boolean isAppForeground; private Handler delayedChecker = new Handler(); @@ -44,12 +55,12 @@ public void onActivityStarted(Activity activity) { appEnteredForeground(); } - ApptentiveInternal.getInstance().onActivityStarted(activity); + ApptentiveInternal.getInstance().onActivityStarted(activity); // TODO: post a notification here } @Override public void onActivityResumed(Activity activity) { - ApptentiveInternal.getInstance().onActivityResumed(activity); + ApptentiveInternal.getInstance().onActivityResumed(activity); // TODO: post a notification here } @Override @@ -66,6 +77,11 @@ public void onActivityCreated(Activity activity, Bundle savedInstanceState) { } + /** + * Decrements the count of running Activities. If it is now 0, start a task that will check again + * after a small delay. If that task still finds 0 running Activities, it will trigger an appEnteredBackground() + * @param activity + */ @Override public void onActivityStopped(Activity activity) { if (foregroundActivities.decrementAndGet() < 0) { @@ -84,9 +100,13 @@ public void onActivityStopped(Activity activity) { delayedChecker.postDelayed(checkFgBgRoutine = new Runnable() { @Override public void run() { - if (foregroundActivities.get() == 0 && isAppForeground) { - appEnteredBackground(); - isAppForeground = false; + try { + if (foregroundActivities.get() == 0 && isAppForeground) { + appEnteredBackground(); + isAppForeground = false; + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception in delayed checking"); } } }, CHECK_DELAY_SHORT); @@ -107,16 +127,16 @@ private void appEnteredForeground() { private void appEnteredBackground() { ApptentiveLog.d("App went to background."); - ApptentiveInternal.getInstance().onAppEnterBackground(); + ApptentiveInternal.getInstance().onAppEnterBackground(); // TODO: post a notification here // Mark entering background as app exit appExited(ApptentiveInternal.getInstance().getApplicationContext()); } private void appLaunched(Context appContext) { - ApptentiveInternal.getInstance().onAppLaunch(appContext); + ApptentiveInternal.getInstance().onAppLaunch(appContext); // TODO: post a notification here } private void appExited(Context appContext) { - ApptentiveInternal.getInstance().onAppExit(appContext); + ApptentiveInternal.getInstance().onAppExit(appContext); // TODO: post a notification here } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/listeners/OnUserLogOutListener.java b/apptentive/src/main/java/com/apptentive/android/sdk/listeners/OnUserLogOutListener.java new file mode 100644 index 000000000..997c871c0 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/listeners/OnUserLogOutListener.java @@ -0,0 +1,8 @@ +package com.apptentive.android.sdk.listeners; + +/** + * Interface definition for a callback to be invoked when user is logged out. + */ +public interface OnUserLogOutListener { + void onUserLogOut(); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/migration/Migrator.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/Migrator.java new file mode 100644 index 000000000..7f4efc3c8 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/Migrator.java @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.migration; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.apptentive.android.sdk.Apptentive; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.migration.v4_0_0.CodePointStore; +import com.apptentive.android.sdk.migration.v4_0_0.VersionHistoryEntry; +import com.apptentive.android.sdk.migration.v4_0_0.VersionHistoryStore; +import com.apptentive.android.sdk.storage.AppRelease; +import com.apptentive.android.sdk.storage.CustomData; +import com.apptentive.android.sdk.storage.Device; +import com.apptentive.android.sdk.storage.EventData; +import com.apptentive.android.sdk.storage.EventRecord; +import com.apptentive.android.sdk.storage.IntegrationConfig; +import com.apptentive.android.sdk.storage.IntegrationConfigItem; +import com.apptentive.android.sdk.storage.Person; +import com.apptentive.android.sdk.storage.Sdk; +import com.apptentive.android.sdk.storage.VersionHistory; +import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.Iterator; +import java.util.Map; + +public class Migrator { + + private Conversation conversation; + private SharedPreferences prefs; + private Context context; + + public Migrator(Context context, SharedPreferences prefs, Conversation conversation) { + this.context = context; + this.prefs = prefs; + this.conversation = conversation; + } + + public void migrate() { + // Miscellaneous + conversation.setLastSeenSdkVersion(prefs.getString(Constants.PREF_KEY_LAST_SEEN_SDK_VERSION, null)); + + migrateDevice(); + migrateSdk(); + migrateAppRelease(); + migratePerson(); + migrateMessageCenter(); + migrateVersionHistory(); + migrateEventData(); + } + + private static final String INTEGRATION_APPTENTIVE_PUSH = "apptentive_push"; + private static final String INTEGRATION_PARSE = "parse"; + private static final String INTEGRATION_URBAN_AIRSHIP = "urban_airship"; + private static final String INTEGRATION_AWS_SNS = "aws_sns"; + + private static final String INTEGRATION_PUSH_TOKEN = "token"; + + private void migrateDevice() { + try { + String deviceString = prefs.getString(Constants.PREF_KEY_DEVICE, null); + if (deviceString != null) { + com.apptentive.android.sdk.migration.v4_0_0.Device deviceOld = new com.apptentive.android.sdk.migration.v4_0_0.Device(deviceString); + Device device = new Device(); + + device.setUuid(deviceOld.getUuid()); + device.setOsName(deviceOld.getOsName()); + device.setOsVersion(deviceOld.getOsVersion()); + device.setOsBuild(deviceOld.getOsBuild()); + String osApiLevel = deviceOld.getOsApiLevel(); + if (!StringUtils.isNullOrEmpty(osApiLevel)) { + device.setOsApiLevel(Integer.parseInt(osApiLevel)); + } + device.setManufacturer(deviceOld.getManufacturer()); + device.setModel(deviceOld.getModel()); + device.setBoard(deviceOld.getBoard()); + device.setProduct(deviceOld.getProduct()); + device.setBrand(deviceOld.getBrand()); + device.setCpu(deviceOld.getCpu()); + device.setDevice(deviceOld.getDevice()); + device.setCarrier(deviceOld.getCarrier()); + device.setCurrentCarrier(deviceOld.getCurrentCarrier()); + device.setNetworkType(deviceOld.getNetworkType()); + device.setBuildType(deviceOld.getBuildType()); + device.setBuildId(deviceOld.getBuildId()); + device.setBootloaderVersion(deviceOld.getBootloaderVersion()); + device.setRadioVersion(deviceOld.getRadioVersion()); + device.setLocaleCountryCode(deviceOld.getLocaleCountryCode()); + device.setLocaleLanguageCode(deviceOld.getLocaleLanguageCode()); + device.setLocaleRaw(deviceOld.getLocaleRaw()); + device.setUtcOffset(deviceOld.getUtcOffset()); + + JSONObject customDataOld = deviceOld.getCustomData(); + if (customDataOld != null) { + CustomData customData = new CustomData(); + Iterator it = customDataOld.keys(); + while (it.hasNext()) { + String key = (String) it.next(); + Object value = customDataOld.get(key); + if (value instanceof JSONObject) { + customData.put(key, jsonObjectToSerializableType((JSONObject) value)); + } else { + customData.put(key, (Serializable) value); + } + } + device.setCustomData(customData); + } + + JSONObject integrationConfigOld = deviceOld.getIntegrationConfig(); + if (integrationConfigOld != null) { + IntegrationConfig integrationConfig = new IntegrationConfig(); + Iterator it = integrationConfigOld.keys(); + while (it.hasNext()) { + String key = (String) it.next(); + IntegrationConfigItem item = new IntegrationConfigItem(integrationConfigOld); + switch (key) { + case INTEGRATION_APPTENTIVE_PUSH: + integrationConfig.setApptentive(item); + break; + case INTEGRATION_PARSE: + integrationConfig.setParse(item); + break; + case INTEGRATION_URBAN_AIRSHIP: + integrationConfig.setUrbanAirship(item); + break; + case INTEGRATION_AWS_SNS: + integrationConfig.setAmazonAwsSns(item); + break; + } + } + device.setIntegrationConfig(integrationConfig); + } + conversation.setDevice(device); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Error migrating Device."); + } + } + + private void migrateSdk() { + String sdkString = prefs.getString(Constants.PREF_KEY_SDK, null); + if (sdkString != null) { + try { + com.apptentive.android.sdk.migration.v4_0_0.Sdk sdkOld = new com.apptentive.android.sdk.migration.v4_0_0.Sdk(sdkString); + Sdk sdk = new Sdk(); + sdk.setVersion(sdkOld.getVersion()); + sdk.setDistribution(sdkOld.getDistribution()); + sdk.setDistributionVersion(sdkOld.getDistributionVersion()); + sdk.setPlatform(sdkOld.getPlatform()); + sdk.setProgrammingLanguage(sdkOld.getProgrammingLanguage()); + sdk.setAuthorName(sdkOld.getAuthorName()); + sdk.setAuthorEmail(sdkOld.getAuthorEmail()); + conversation.setSdk(sdk); + } catch (Exception e) { + ApptentiveLog.e(e, "Error migrating Sdk."); + } + } + } + + private void migrateAppRelease() { + String appReleaseString = prefs.getString(Constants.PREF_KEY_APP_RELEASE, null); + if (appReleaseString != null) { + try { + com.apptentive.android.sdk.migration.v4_0_0.AppRelease appReleaseOld = new com.apptentive.android.sdk.migration.v4_0_0.AppRelease(appReleaseString); + AppRelease appRelease = new AppRelease(); + appRelease.setAppStore(appReleaseOld.getAppStore()); + appRelease.setDebug(appReleaseOld.getDebug()); + appRelease.setIdentifier(appReleaseOld.getIdentifier()); + appRelease.setInheritStyle(appReleaseOld.getInheritStyle()); + appRelease.setOverrideStyle(appReleaseOld.getOverrideStyle()); + appRelease.setTargetSdkVersion(appReleaseOld.getTargetSdkVersion()); + appRelease.setType(appReleaseOld.getType()); + appRelease.setVersionCode(appReleaseOld.getVersionCode()); + appRelease.setVersionName(appReleaseOld.getVersionName()); + conversation.setAppRelease(appRelease); + } catch (Exception e) { + ApptentiveLog.e(e, "Error migrating AppRelease."); + } + } + } + + private void migratePerson() { + String personString = prefs.getString(Constants.PREF_KEY_PERSON, null); + + if (personString != null) { + try { + Person person = new Person(); + com.apptentive.android.sdk.migration.v4_0_0.Person personOld = new com.apptentive.android.sdk.migration.v4_0_0.Person(personString); + person.setEmail(personOld.getEmail()); + person.setBirthday(personOld.getBirthday()); + person.setCity(personOld.getCity()); + person.setCountry(personOld.getCountry()); + person.setFacebookId(personOld.getFacebookId()); + person.setId(personOld.getId()); + person.setName(personOld.getName()); + person.setPhoneNumber(personOld.getPhoneNumber()); + person.setStreet(personOld.getStreet()); + person.setZip(personOld.getZip()); + + JSONObject customDataOld = personOld.getCustomData(); + if (customDataOld != null) { + CustomData customData = new CustomData(); + Iterator it = customDataOld.keys(); + while (it.hasNext()) { + String key = (String) it.next(); + Object value = customDataOld.get(key); + if (value instanceof JSONObject) { + customData.put(key, jsonObjectToSerializableType((JSONObject) value)); + } else { + customData.put(key, (Serializable) value); + } + } + person.setCustomData(customData); + } + conversation.setPerson(person); + } catch (Exception e) { + ApptentiveLog.e(e, "Error migrating Person."); + } + } + } + + private void migrateMessageCenter() { + conversation.setMessageCenterFeatureUsed(prefs.getBoolean(Constants.PREF_KEY_MESSAGE_CENTER_FEATURE_USED, false)); + conversation.setMessageCenterWhoCardPreviouslyDisplayed(prefs.getBoolean(Constants.PREF_KEY_MESSAGE_CENTER_WHO_CARD_DISPLAYED_BEFORE, false)); + } + + private void migrateVersionHistory() { + // An existing static initializer will trigger the V1 to V2 migration of VersionHistory when VersionHistoryStore is loaded below. + + // Then migrate to V3, which is stored in the Conversation object. + JSONArray versionHistoryOld = VersionHistoryStore.getBaseArray(); + try { + if (versionHistoryOld != null && versionHistoryOld.length() > 0) { + VersionHistory versionHistory = conversation.getVersionHistory(); + for (int i = 0; i < versionHistoryOld.length(); i++) { + VersionHistoryEntry versionHistoryEntryOld = new VersionHistoryEntry((JSONObject) versionHistoryOld.get(i)); + versionHistory.updateVersionHistory(versionHistoryEntryOld.getTimestamp(), versionHistoryEntryOld.getVersionCode(), versionHistoryEntryOld.getVersionName()); + } + } + } catch (Exception e) { + ApptentiveLog.w(e, "Error migrating VersionHistory entries V2 to V3."); + } + } + + private void migrateEventData() { + EventData eventData = conversation.getEventData(); + String codePointString = prefs.getString(Constants.PREF_KEY_CODE_POINT_STORE, null); + try { + CodePointStore codePointStore = new CodePointStore(codePointString); + Map migratedEvents = codePointStore.migrateCodePoints(); + Map migratedInteractions = codePointStore.migrateInteractions(); + if (migratedEvents != null) { + eventData.setEvents(migratedEvents); + } + if (migratedInteractions != null) { + eventData.setInteractions(migratedInteractions); + } + } catch (Exception e) { + ApptentiveLog.w(e, "Error migrating Event Data."); + } + } + + /** + * Takes a legacy Apptentive Custom Data object base on JSON, and returns the modern serializable version + */ + private static Serializable jsonObjectToSerializableType(JSONObject input) { + String type = input.optString(Apptentive.Version.KEY_TYPE, null); + try { + if (type != null) { + if (type.equals(Apptentive.Version.TYPE)) { + return new Apptentive.Version(input); + } else if (type.equals(Apptentive.DateTime.TYPE)) { + return new Apptentive.DateTime(input); + } + } + } catch (JSONException e) { + ApptentiveLog.e(e, "Error migrating JSONObject."); + } + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/AppRelease.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/AppRelease.java similarity index 63% rename from apptentive/src/main/java/com/apptentive/android/sdk/model/AppRelease.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/AppRelease.java index 48fbd5c44..e6a7ffcad 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/AppRelease.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/AppRelease.java @@ -1,24 +1,17 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.model; +package com.apptentive.android.sdk.migration.v4_0_0; -import android.content.Context; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.os.Bundle; - -import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.util.Util; import org.json.JSONException; +import org.json.JSONObject; -public class AppRelease extends Payload { +public class AppRelease extends JSONObject { private static final String KEY_TYPE = "type"; private static final String KEY_VERSION_NAME = "version_name"; @@ -34,14 +27,6 @@ public AppRelease(String json) throws JSONException { super(json); } - public AppRelease() { - super(); - } - - public void initBaseType() { - setBaseType(BaseType.app_release); - } - public String getType() { if (!isNull(KEY_TYPE)) { return optString(KEY_TYPE, null); @@ -169,44 +154,4 @@ public void setDebug(boolean debug) { ApptentiveLog.w("Error adding %s to AppRelease.", KEY_DEBUG); } } - - public static AppRelease generateCurrentAppRelease(Context context) { - - AppRelease appRelease = new AppRelease(); - - String appPackageName = context.getPackageName(); - PackageManager packageManager = context.getPackageManager(); - - int currentVersionCode = 0; - String currentVersionName = "0"; - int targetSdkVersion = 0; - boolean isAppDebuggable = false; - try { - PackageInfo packageInfo = packageManager.getPackageInfo(appPackageName, PackageManager.GET_META_DATA | PackageManager.GET_RECEIVERS); - ApplicationInfo ai = packageInfo.applicationInfo; - currentVersionCode = packageInfo.versionCode; - currentVersionName = packageInfo.versionName; - targetSdkVersion = packageInfo.applicationInfo.targetSdkVersion; - Bundle metaData = ai.metaData; - if (metaData != null) { - isAppDebuggable = (ai.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; - } - } catch (PackageManager.NameNotFoundException e) { - ApptentiveLog.e("Failed to read app's PackageInfo."); - } - - int themeOverrideResId = context.getResources().getIdentifier("ApptentiveThemeOverride", "style", appPackageName); - - appRelease.setType("android"); - appRelease.setVersionName(currentVersionName); - appRelease.setIdentifier(appPackageName); - appRelease.setVersionCode(currentVersionCode); - appRelease.setTargetSdkVersion(String.valueOf(targetSdkVersion)); - appRelease.setAppStore(Util.getInstallerPackageName(context)); - appRelease.setInheritStyle(ApptentiveInternal.getInstance().isAppUsingAppCompatTheme()); - appRelease.setOverrideStyle(themeOverrideResId != 0); - appRelease.setDebug(isAppDebuggable); - - return appRelease; - } -} +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/CodePointStore.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/CodePointStore.java new file mode 100644 index 000000000..f656bdc46 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/CodePointStore.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.migration.v4_0_0; + +import com.apptentive.android.sdk.storage.EventRecord; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; + +/** + *

    All public methods altering code point values should be synchronized.

    + *

    Example:

    + *
    + * {
    + *   "code_point": {
    + *     "codePoint1": {
    + *       "last": 1234567890,
    + *       "total": 6,
    + *       "version": {
    + *         "1.1": 4,
    + *         "1.2": 2
    + *       },
    + *       "build": {
    + *         "5": 4,
    + *         "6": 2
    + *       }
    + *     }
    + *   },
    + *   "interactions": {
    + *     "526fe2836dd8bf546a00000c": {
    + *       "last": 1234567890.4,
    + *       "total": 6,
    + *       "version": {
    + *         "1.1": 4,
    + *         "1.2": 2
    + *       },
    + *       "build": {
    + *         "5": 4,
    + *         "6": 2
    + *       }
    + *     }
    + *   }
    + * }
    + * 
    + */ +public class CodePointStore { + + private JSONObject store; + + private static final String KEY_CODE_POINT = "code_point"; + private static final String KEY_INTERACTIONS = "interactions"; + private static final String KEY_LAST = "last"; // The last time this codepoint was seen. + private static final String KEY_TOTAL = "total"; // The total times this code point was seen. + private static final String KEY_VERSION_NAME = "version"; + private static final String KEY_VERSION_CODE = "build"; + + public CodePointStore(String json) throws JSONException { + store = new JSONObject(json); + } + + public Map migrateCodePoints() throws JSONException { + return migrateRecords(false); + } + + public Map migrateInteractions() throws JSONException { + return migrateRecords(true); + } + + public Map migrateRecords(boolean interaction) throws JSONException { + JSONObject recordContainer = store.optJSONObject(interaction ? KEY_INTERACTIONS : KEY_CODE_POINT); + if (recordContainer != null) { + Map ret = new HashMap<>(); + Iterator recordNames = recordContainer.keys(); + while (recordNames.hasNext()) { + String recordName = recordNames.next(); + JSONObject record = recordContainer.getJSONObject(recordName); + EventRecord eventRecord = new EventRecord(); + eventRecord.setLast(record.getDouble(KEY_LAST)); + eventRecord.setTotal(record.getLong(KEY_TOTAL)); + + Map versionCodes = new HashMap<>(); + JSONObject versionCodesOld = record.getJSONObject(KEY_VERSION_CODE); + if (versionCodesOld != null) { + Iterator versionCodesIterator = versionCodesOld.keys(); + if (versionCodesIterator != null) { + while (versionCodesIterator.hasNext()) { + String versionCodeOld = versionCodesIterator.next(); + Long count = versionCodesOld.getLong(versionCodeOld); + versionCodes.put(Integer.parseInt(versionCodeOld), count); + } + } + eventRecord.setVersionCodes(versionCodes); + } + + Map versionNames = new HashMap<>(); + JSONObject versionNamesOld = record.getJSONObject(KEY_VERSION_NAME); + if (versionNamesOld != null) { + Iterator versionNamesIterator = versionNamesOld.keys(); + if (versionNamesIterator != null) { + while (versionNamesIterator.hasNext()) { + String versionNameOld = versionNamesIterator.next(); + Long count = versionNamesOld.getLong(versionNameOld); + versionNames.put(versionNameOld, count); + } + } + eventRecord.setVersionNames(versionNames); + } + ret.put(recordName, eventRecord); + } + return ret; + } + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/Device.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Device.java similarity index 86% rename from apptentive/src/main/java/com/apptentive/android/sdk/model/Device.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Device.java index 8e2af6154..57d568396 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/Device.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Device.java @@ -1,21 +1,17 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.model; +package com.apptentive.android.sdk.migration.v4_0_0; import com.apptentive.android.sdk.ApptentiveLog; import org.json.JSONException; +import org.json.JSONObject; -/** - * @author Sky Kelsey - */ -public class Device extends Payload { - - public static final String KEY = "device"; +public class Device extends JSONObject { private static final String KEY_UUID = "uuid"; private static final String KEY_OS_NAME = "os_name"; @@ -28,7 +24,7 @@ public class Device extends Payload { private static final String KEY_PRODUCT = "product"; private static final String KEY_BRAND = "brand"; private static final String KEY_CPU = "cpu"; - private static final String KEY_DEVICE = "device"; // + private static final String KEY_DEVICE = "device"; private static final String KEY_CARRIER = "carrier"; private static final String KEY_CURRENT_CARRIER = "current_carrier"; private static final String KEY_NETWORK_TYPE = "network_type"; @@ -36,29 +32,20 @@ public class Device extends Payload { private static final String KEY_BUILD_ID = "build_id"; private static final String KEY_BOOTLOADER_VERSION = "bootloader_version"; private static final String KEY_RADIO_VERSION = "radio_version"; - public static final String KEY_CUSTOM_DATA = "custom_data"; + private static final String KEY_CUSTOM_DATA = "custom_data"; private static final String KEY_LOCALE_COUNTRY_CODE = "locale_country_code"; private static final String KEY_LOCALE_LANGUAGE_CODE = "locale_language_code"; private static final String KEY_LOCALE_RAW = "locale_raw"; private static final String KEY_UTC_OFFSET = "utc_offset"; private static final String KEY_INTEGRATION_CONFIG = "integration_config"; - - public Device() { - super(); - } - public Device(String json) throws JSONException { super(json); } - public void initBaseType() { - setBaseType(BaseType.device); - } - public String getUuid() { try { - if(!isNull(KEY_UUID)) { + if (!isNull(KEY_UUID)) { return getString(KEY_UUID); } } catch (JSONException e) { @@ -77,7 +64,7 @@ public void setUuid(String uuid) { public String getOsName() { try { - if(!isNull(KEY_OS_NAME)) { + if (!isNull(KEY_OS_NAME)) { return getString(KEY_OS_NAME); } } catch (JSONException e) { @@ -96,7 +83,7 @@ public void setOsName(String osName) { public String getOsVersion() { try { - if(!isNull(KEY_OS_VERSION)) { + if (!isNull(KEY_OS_VERSION)) { return getString(KEY_OS_VERSION); } } catch (JSONException e) { @@ -115,7 +102,7 @@ public void setOsVersion(String osVersion) { public String getOsBuild() { try { - if(!isNull(KEY_OS_BUILD)) { + if (!isNull(KEY_OS_BUILD)) { return getString(KEY_OS_BUILD); } } catch (JSONException e) { @@ -134,7 +121,7 @@ public void setOsBuild(String osBuild) { public String getOsApiLevel() { try { - if(!isNull(KEY_OS_API_LEVEL)) { + if (!isNull(KEY_OS_API_LEVEL)) { return getString(KEY_OS_API_LEVEL); } } catch (JSONException e) { @@ -153,7 +140,7 @@ public void setOsApiLevel(String osApiLevel) { public String getManufacturer() { try { - if(!isNull(KEY_MANUFACTURER)) { + if (!isNull(KEY_MANUFACTURER)) { return getString(KEY_MANUFACTURER); } } catch (JSONException e) { @@ -172,7 +159,7 @@ public void setManufacturer(String manufacturer) { public String getModel() { try { - if(!isNull(KEY_MODEL)) { + if (!isNull(KEY_MODEL)) { return getString(KEY_MODEL); } } catch (JSONException e) { @@ -191,7 +178,7 @@ public void setModel(String model) { public String getBoard() { try { - if(!isNull(KEY_BOARD)) { + if (!isNull(KEY_BOARD)) { return getString(KEY_BOARD); } } catch (JSONException e) { @@ -210,7 +197,7 @@ public void setBoard(String board) { public String getProduct() { try { - if(!isNull(KEY_PRODUCT)) { + if (!isNull(KEY_PRODUCT)) { return getString(KEY_PRODUCT); } } catch (JSONException e) { @@ -229,7 +216,7 @@ public void setProduct(String product) { public String getBrand() { try { - if(!isNull(KEY_BRAND)) { + if (!isNull(KEY_BRAND)) { return getString(KEY_BRAND); } } catch (JSONException e) { @@ -248,7 +235,7 @@ public void setBrand(String brand) { public String getCpu() { try { - if(!isNull(KEY_CPU)) { + if (!isNull(KEY_CPU)) { return getString(KEY_CPU); } } catch (JSONException e) { @@ -267,7 +254,7 @@ public void setCpu(String cpu) { public String getDevice() { try { - if(!isNull(KEY_DEVICE)) { + if (!isNull(KEY_DEVICE)) { return getString(KEY_DEVICE); } } catch (JSONException e) { @@ -286,7 +273,7 @@ public void setDevice(String device) { public String getCarrier() { try { - if(!isNull(KEY_CARRIER)) { + if (!isNull(KEY_CARRIER)) { return getString(KEY_CARRIER); } } catch (JSONException e) { @@ -305,7 +292,7 @@ public void setCarrier(String carrier) { public String getCurrentCarrier() { try { - if(!isNull(KEY_CURRENT_CARRIER)) { + if (!isNull(KEY_CURRENT_CARRIER)) { return getString(KEY_CURRENT_CARRIER); } } catch (JSONException e) { @@ -324,7 +311,7 @@ public void setCurrentCarrier(String currentCarrier) { public String getNetworkType() { try { - if(!isNull(KEY_NETWORK_TYPE)) { + if (!isNull(KEY_NETWORK_TYPE)) { return getString(KEY_NETWORK_TYPE); } } catch (JSONException e) { @@ -343,7 +330,7 @@ public void setNetworkType(String networkType) { public String getBuildType() { try { - if(!isNull(KEY_BUILD_TYPE)) { + if (!isNull(KEY_BUILD_TYPE)) { return getString(KEY_BUILD_TYPE); } } catch (JSONException e) { @@ -362,7 +349,7 @@ public void setBuildType(String buildType) { public String getBuildId() { try { - if(!isNull(KEY_BUILD_ID)) { + if (!isNull(KEY_BUILD_ID)) { return getString(KEY_BUILD_ID); } } catch (JSONException e) { @@ -381,7 +368,7 @@ public void setBuildId(String buildId) { public String getBootloaderVersion() { try { - if(!isNull(KEY_BOOTLOADER_VERSION)) { + if (!isNull(KEY_BOOTLOADER_VERSION)) { return getString(KEY_BOOTLOADER_VERSION); } } catch (JSONException e) { @@ -400,7 +387,7 @@ public void setBootloaderVersion(String bootloaderVersion) { public String getRadioVersion() { try { - if(!isNull(KEY_RADIO_VERSION)) { + if (!isNull(KEY_RADIO_VERSION)) { return getString(KEY_RADIO_VERSION); } } catch (JSONException e) { @@ -418,10 +405,10 @@ public void setRadioVersion(String radioVersion) { } @SuppressWarnings("unchecked") // We check it coming in. - public CustomData getCustomData() { - if(!isNull(KEY_CUSTOM_DATA)) { + public JSONObject getCustomData() { + if (!isNull(KEY_CUSTOM_DATA)) { try { - return new CustomData(getJSONObject(KEY_CUSTOM_DATA)); + return getJSONObject(KEY_CUSTOM_DATA); } catch (JSONException e) { // Ignore } @@ -429,7 +416,7 @@ public CustomData getCustomData() { return null; } - public void setCustomData(CustomData customData) { + public void setCustomData(JSONObject customData) { try { put(KEY_CUSTOM_DATA, customData); } catch (JSONException e) { @@ -438,10 +425,10 @@ public void setCustomData(CustomData customData) { } @SuppressWarnings("unchecked") // We check it coming in. - public CustomData getIntegrationConfig() { - if(!isNull(KEY_INTEGRATION_CONFIG)) { + public JSONObject getIntegrationConfig() { + if (!isNull(KEY_INTEGRATION_CONFIG)) { try { - return new CustomData(getJSONObject(KEY_INTEGRATION_CONFIG)); + return getJSONObject(KEY_INTEGRATION_CONFIG); } catch (JSONException e) { // Ignore } @@ -449,7 +436,7 @@ public CustomData getIntegrationConfig() { return null; } - public void setIntegrationConfig(CustomData integrationConfig) { + public void setIntegrationConfig(JSONObject integrationConfig) { try { put(KEY_INTEGRATION_CONFIG, integrationConfig); } catch (JSONException e) { @@ -459,7 +446,7 @@ public void setIntegrationConfig(CustomData integrationConfig) { public String getLocaleCountryCode() { try { - if(!isNull(KEY_LOCALE_COUNTRY_CODE)) { + if (!isNull(KEY_LOCALE_COUNTRY_CODE)) { return getString(KEY_LOCALE_COUNTRY_CODE); } } catch (JSONException e) { @@ -478,7 +465,7 @@ public void setLocaleCountryCode(String localeCountryCode) { public String getLocaleLanguageCode() { try { - if(!isNull(KEY_LOCALE_LANGUAGE_CODE)) { + if (!isNull(KEY_LOCALE_LANGUAGE_CODE)) { return getString(KEY_LOCALE_LANGUAGE_CODE); } } catch (JSONException e) { @@ -497,7 +484,7 @@ public void setLocaleLanguageCode(String localeLanguageCode) { public String getLocaleRaw() { try { - if(!isNull(KEY_LOCALE_RAW)) { + if (!isNull(KEY_LOCALE_RAW)) { return getString(KEY_LOCALE_RAW); } } catch (JSONException e) { @@ -516,7 +503,7 @@ public void setLocaleRaw(String localeRaw) { public String getUtcOffset() { try { - if(!isNull(KEY_UTC_OFFSET)) { + if (!isNull(KEY_UTC_OFFSET)) { return getString(KEY_UTC_OFFSET); } } catch (JSONException e) { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/Person.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Person.java similarity index 70% rename from apptentive/src/main/java/com/apptentive/android/sdk/model/Person.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Person.java index b9a314f5f..77bc0e9c0 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/Person.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Person.java @@ -1,21 +1,15 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; +package com.apptentive.android.sdk.migration.v4_0_0; import org.json.JSONException; +import org.json.JSONObject; -/** - * @author Sky Kelsey - */ -public class Person extends Payload { - - public static final String KEY = "person"; +public class Person extends JSONObject { private static final String KEY_ID = "id"; private static final String KEY_EMAIL = "email"; @@ -27,27 +21,19 @@ public class Person extends Payload { private static final String KEY_ZIP = "zip"; private static final String KEY_COUNTRY = "country"; private static final String KEY_BIRTHDAY = "birthday"; - public static final String KEY_CUSTOM_DATA = "custom_data"; - - public Person() { - super(); - } + private static final String KEY_CUSTOM_DATA = "custom_data"; public Person(String json) throws JSONException { super(json); } - public void initBaseType() { - setBaseType(BaseType.person); - } - public String getId() { try { if (!isNull(KEY_ID)) { return getString(KEY_ID); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -56,7 +42,7 @@ public void setId(String id) { try { put(KEY_ID, id); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_ID + " of " + KEY); + // Can't happen } } @@ -66,7 +52,7 @@ public String getEmail() { return getString(KEY_EMAIL); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -75,7 +61,7 @@ public void setEmail(String email) { try { put(KEY_EMAIL, email); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_EMAIL + " of " + KEY); + // Can't happen } } @@ -85,7 +71,7 @@ public String getName() { return getString(KEY_NAME); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -94,7 +80,7 @@ public void setName(String name) { try { put(KEY_NAME, name); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_NAME + " of " + KEY); + // Can't happen } } @@ -104,7 +90,7 @@ public String getFacebookId() { return getString(KEY_FACEBOOK_ID); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -113,7 +99,7 @@ public void setFacebookId(String facebookId) { try { put(KEY_FACEBOOK_ID, facebookId); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_FACEBOOK_ID + " of " + KEY); + // Can't happen } } @@ -123,7 +109,7 @@ public String getPhoneNumber() { return getString(KEY_PHONE_NUMBER); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -132,7 +118,7 @@ public void setPhoneNumber(String phoneNumber) { try { put(KEY_PHONE_NUMBER, phoneNumber); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_PHONE_NUMBER + " of " + KEY); + // Can't happen } } @@ -142,7 +128,7 @@ public String getStreet() { return getString(KEY_STREET); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -151,7 +137,7 @@ public void setStreet(String street) { try { put(KEY_STREET, street); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_STREET + " of " + KEY); + // Can't happen } } @@ -161,7 +147,7 @@ public String getCity() { return getString(KEY_CITY); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -170,7 +156,7 @@ public void setCity(String city) { try { put(KEY_CITY, city); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_CITY + " of " + KEY); + // Can't happen } } @@ -180,7 +166,7 @@ public String getZip() { return getString(KEY_ZIP); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -189,7 +175,7 @@ public void setZip(String zip) { try { put(KEY_ZIP, zip); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_ZIP + " of " + KEY); + // Can't happen } } @@ -199,7 +185,7 @@ public String getCountry() { return getString(KEY_COUNTRY); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -208,7 +194,7 @@ public void setCountry(String country) { try { put(KEY_COUNTRY, country); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_COUNTRY + " of " + KEY); + // Can't happen } } @@ -218,7 +204,7 @@ public String getBirthday() { return getString(KEY_BIRTHDAY); } } catch (JSONException e) { - // Ignore + // Can't happen } return null; } @@ -227,27 +213,27 @@ public void setBirthday(String birthday) { try { put(KEY_BIRTHDAY, birthday); } catch (JSONException e) { - ApptentiveLog.e("Unable to set field " + KEY_BIRTHDAY + " of " + KEY); + // Can't happen } } @SuppressWarnings("unchecked") // We check it coming in. - public CustomData getCustomData() { - if(!isNull(KEY_CUSTOM_DATA)) { + public JSONObject getCustomData() { + if (!isNull(KEY_CUSTOM_DATA)) { try { - return new CustomData(getJSONObject(KEY_CUSTOM_DATA)); + return getJSONObject(KEY_CUSTOM_DATA); } catch (JSONException e) { - // Ignore + // Can't happen } } return null; } - public void setCustomData(CustomData customData) { + public void setCustomData(JSONObject customData) { try { put(KEY_CUSTOM_DATA, customData); } catch (JSONException e) { - ApptentiveLog.w("Error adding %s to Device.", KEY_CUSTOM_DATA); + // Can't happen } } -} +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/Sdk.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Sdk.java similarity index 92% rename from apptentive/src/main/java/com/apptentive/android/sdk/model/Sdk.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Sdk.java index 929fe5f46..eef52f973 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/Sdk.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/Sdk.java @@ -1,20 +1,16 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.model; +package com.apptentive.android.sdk.migration.v4_0_0; import com.apptentive.android.sdk.ApptentiveLog; import org.json.JSONException; +import org.json.JSONObject; -/** - * @author Sky Kelsey - */ -public class Sdk extends Payload { - - public static final String KEY = "sdk"; +public class Sdk extends JSONObject { private static final String KEY_VERSION = "version"; private static final String KEY_PROGRAMMING_LANGUAGE = "programming_language"; @@ -28,14 +24,6 @@ public Sdk(String json) throws JSONException { super(json); } - public Sdk() { - super(); - } - - public void initBaseType() { - setBaseType(BaseType.sdk); - } - public String getVersion() { try { if(!isNull(KEY_VERSION)) { @@ -168,4 +156,4 @@ public void setDistributionVersion(String distributionVersion) { ApptentiveLog.w("Error adding %s to Sdk.", KEY_DISTRIBUTION_VERSION); } } -} +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryEntry.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryEntry.java similarity index 91% rename from apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryEntry.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryEntry.java index bb63cd3ac..7c99ad607 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryEntry.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryEntry.java @@ -4,7 +4,7 @@ * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.storage; +package com.apptentive.android.sdk.migration.v4_0_0; import org.json.JSONException; import org.json.JSONObject; @@ -15,7 +15,7 @@ public class VersionHistoryEntry extends JSONObject { private static final String KEY_VERSION_NAME = "versionName"; private static final String KEY_TIMESTAMP = "timestamp"; - VersionHistoryEntry(JSONObject jsonObject) throws JSONException { + public VersionHistoryEntry(JSONObject jsonObject) throws JSONException { this(jsonObject.toString()); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryStore.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryStore.java similarity index 95% rename from apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryStore.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryStore.java index f74811c0e..285940586 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryStore.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryStore.java @@ -4,7 +4,7 @@ * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.storage; +package com.apptentive.android.sdk.migration.v4_0_0; import android.content.SharedPreferences; @@ -36,7 +36,7 @@ private VersionHistoryStore() { } private static void save() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); JSONArray baseArray = getBaseArray(); if (baseArray != null) { prefs.edit().putString(Constants.PREF_KEY_VERSION_HISTORY_V2, baseArray.toString()).apply(); @@ -46,7 +46,7 @@ private static void save() { private static void ensureLoaded() { if (versionHistoryEntries == null) { versionHistoryEntries = new ArrayList(); - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); try { String json = prefs.getString(Constants.PREF_KEY_VERSION_HISTORY_V2, "[]"); JSONArray baseArray = new JSONArray(json); @@ -55,13 +55,13 @@ private static void ensureLoaded() { versionHistoryEntries.add(entry); } } catch (Exception e) { - ApptentiveLog.w("Error loading VersionHistoryStore.", e); + ApptentiveLog.w(e, "Error loading VersionHistoryStore."); } } } public static synchronized void clear() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); prefs.edit().remove(Constants.PREF_KEY_VERSION_HISTORY_V2).apply(); versionHistoryEntries.clear(); } @@ -87,7 +87,7 @@ public static synchronized void updateVersionHistory(Integer newVersionCode, Str save(); } } catch (Exception e) { - ApptentiveLog.w("Error updating VersionHistoryStore.", e); + ApptentiveLog.w(e, "Error updating VersionHistoryStore."); } } @@ -163,6 +163,7 @@ public static synchronized VersionHistoryEntry getLastVersionSeen() { * @return the base JSONArray */ public static JSONArray getBaseArray() { + ensureLoaded(); JSONArray baseArray = new JSONArray(); for (VersionHistoryEntry entry : versionHistoryEntries) { baseArray.put(entry); diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryStoreMigrator.java b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryStoreMigrator.java similarity index 90% rename from apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryStoreMigrator.java rename to apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryStoreMigrator.java index fdc8c70a0..31320d004 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryStoreMigrator.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/migration/v4_0_0/VersionHistoryStoreMigrator.java @@ -1,10 +1,10 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.storage; +package com.apptentive.android.sdk.migration.v4_0_0; import android.content.SharedPreferences; @@ -12,7 +12,7 @@ import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.util.Constants; -public class VersionHistoryStoreMigrator { +class VersionHistoryStoreMigrator { private static final String OLD_ENTRY_SEP = "__"; private static final String OLD_FIELD_SEP = "--"; @@ -22,7 +22,7 @@ public class VersionHistoryStoreMigrator { private static boolean migrated_to_v2; - public static void migrateV1ToV2(String oldFormat) { + static void migrateV1ToV2(String oldFormat) { ApptentiveLog.i("Migrating VersionHistoryStore V1 to V2."); ApptentiveLog.i("V1: %s", oldFormat); try { @@ -53,7 +53,7 @@ private static void performMigrationIfNeededV1ToV2() { if (migrated_to_v2) { return; } - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); if (prefs != null) { migrated_to_v2 = prefs.getBoolean(Constants.PREF_KEY_VERSION_HISTORY_V2_MIGRATED, false); if (migrated_to_v2) { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/AppReleaseFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/AppReleaseFactory.java deleted file mode 100644 index aa4236bfe..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/AppReleaseFactory.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2013, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; - -import org.json.JSONException; - -public class AppReleaseFactory { - public static AppRelease fromJson(String json) { - try { - return new AppRelease(json); - } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as AppRelease: %s", e, json); - } catch (IllegalArgumentException e) { - // Unknown unknown #rumsfeld - } - return null; - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/AppReleasePayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/AppReleasePayload.java new file mode 100644 index 000000000..0c02cd7fc --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/AppReleasePayload.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.network.HttpRequestMethod; + +import org.json.JSONException; + +public class AppReleasePayload extends JsonPayload { + + public static final String KEY = "app_release"; + + private static final String KEY_TYPE = "type"; + private static final String KEY_VERSION_NAME = "version_name"; + private static final String KEY_VERSION_CODE = "version_code"; + private static final String KEY_IDENTIFIER = "identifier"; + private static final String KEY_TARGET_SDK_VERSION = "target_sdk_version"; + private static final String KEY_APP_STORE = "app_store"; + private static final String KEY_STYLE_INHERIT = "inheriting_styles"; + private static final String KEY_STYLE_OVERRIDE = "overriding_styles"; + private static final String KEY_DEBUG = "debug"; + + public AppReleasePayload() { + super(PayloadType.app_release); + } + + public AppReleasePayload(String json) throws JSONException { + super(PayloadType.app_release, json); + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + throw new RuntimeException(getClass().getName() + " is deprecated"); // FIXME: find a better approach + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + throw new RuntimeException(getClass().getName() + " is deprecated"); // FIXME: find a better approach + } + + @Override + public String getHttpRequestContentType() { + throw new RuntimeException(getClass().getName() + " is deprecated"); // FIXME: find a better approach + } + + //endregion + + public String getType() { + return optString(KEY_TYPE, null); + } + + public void setType(String type) { + put(KEY_TYPE, type); + } + + public String getVersionName() { + return optString(KEY_VERSION_NAME, null); + } + + public void setVersionName(String versionName) { + put(KEY_VERSION_NAME, versionName); + } + + public int getVersionCode() { + return optInt(KEY_VERSION_CODE, -1); + } + + public void setVersionCode(int versionCode) { + put(KEY_VERSION_CODE, versionCode); + } + + public String getIdentifier() { + return optString(KEY_IDENTIFIER, null); + } + + public void setIdentifier(String identifier) { + put(KEY_IDENTIFIER, identifier); + } + + public String getTargetSdkVersion() { + return optString(KEY_TARGET_SDK_VERSION, null); + } + + public void setTargetSdkVersion(String targetSdkVersion) { + put(KEY_TARGET_SDK_VERSION, targetSdkVersion); + } + + public String getAppStore() { + return optString(KEY_APP_STORE, null); + } + + public void setAppStore(String appStore) { + put(KEY_APP_STORE, appStore); + } + + // Flag for whether the apptentive is inheriting styles from the host app + public boolean getInheritStyle() { + return getBoolean(KEY_STYLE_INHERIT); + } + + public void setInheritStyle(boolean inheritStyle) { + put(KEY_STYLE_INHERIT, inheritStyle); + } + + // Flag for whether the app is overriding any Apptentive Styles + public boolean getOverrideStyle() { + return getBoolean(KEY_STYLE_OVERRIDE); + } + + public void setOverrideStyle(boolean overrideStyle) { + put(KEY_STYLE_OVERRIDE, overrideStyle); + } + + public boolean getDebug() { + return getBoolean(KEY_DEBUG); + } + + public void setDebug(boolean debug) { + put(KEY_DEBUG, debug); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/ApptentiveMessage.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/ApptentiveMessage.java similarity index 68% rename from apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/ApptentiveMessage.java rename to apptentive/src/main/java/com/apptentive/android/sdk/model/ApptentiveMessage.java index 6f82cc376..876e46336 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/ApptentiveMessage.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/ApptentiveMessage.java @@ -1,13 +1,13 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.module.messagecenter.model; +package com.apptentive.android.sdk.model; import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.model.ConversationItem; +import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterListItem; import org.json.JSONException; import org.json.JSONObject; @@ -20,10 +20,14 @@ public abstract class ApptentiveMessage extends ConversationItem implements Mess public static final String KEY_CREATED_AT = "created_at"; public static final String KEY_TYPE = "type"; public static final String KEY_HIDDEN = "hidden"; + /** inbound here means inbound to the server. When this is true, the message is outgoing */ + public static final String KEY_INBOUND = "inbound"; public static final String KEY_CUSTOM_DATA = "custom_data"; public static final String KEY_AUTOMATED = "automated"; public static final String KEY_SENDER = "sender"; public static final String KEY_SENDER_ID = "id"; + private static final String KEY_SENDER_NAME = "name"; + private static final String KEY_SENDER_PROFILE_PHOTO = "profile_photo"; // State and Read are not stored in JSON, only in DB. private State state = State.unknown; @@ -32,99 +36,62 @@ public abstract class ApptentiveMessage extends ConversationItem implements Mess // datestamp is only stored in memory, due to how we selectively apply date labeling in the view. private String datestamp; - private static final String KEY_SENDER_NAME = "name"; - private static final String KEY_SENDER_PROFILE_PHOTO = "profile_photo"; protected ApptentiveMessage() { - super(); + super(PayloadType.message); state = State.sending; read = true; // This message originated here. - setBaseType(BaseType.message); initType(); } protected ApptentiveMessage(String json) throws JSONException { - super(json); - } - - protected void initBaseType() { - setBaseType(BaseType.message); + super(PayloadType.message, json); + state = State.unknown; + initType(); } protected abstract void initType(); public void setId(String id) { - try { - put(KEY_ID, id); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_ID); - } + put(KEY_ID, id); } public String getId() { - try { - if (!isNull((KEY_ID))) { - return getString(KEY_ID); - } - } catch (JSONException e) { - // Ignore - } - return null; + return optString(KEY_ID, null); } public Double getCreatedAt() { - try { - return getDouble(KEY_CREATED_AT); - } catch (JSONException e) { - // Ignore - } - return null; + return getDouble(KEY_CREATED_AT); } public void setCreatedAt(Double createdAt) { - try { - put(KEY_CREATED_AT, createdAt); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_CREATED_AT); - } + put(KEY_CREATED_AT, createdAt); } - public Type getType() { - try { - if (isNull((KEY_TYPE))) { - return Type.CompoundMessage; - } - return Type.parse(getString(KEY_TYPE)); - } catch (JSONException e) { - // Ignore + public Type getMessageType() { + if (isNull(KEY_TYPE)) { + return Type.CompoundMessage; } - return Type.unknown; + String typeString = optString(KEY_TYPE, null); + return typeString == null ? Type.unknown : Type.parse(typeString); } protected void setType(Type type) { - try { - put(KEY_TYPE, type.name()); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_TYPE); - } + put(KEY_TYPE, type.name()); } public boolean isHidden() { - try { - return getBoolean(KEY_HIDDEN); - } catch (JSONException e) { - // Ignore - } - return false; + return getBoolean(KEY_HIDDEN); } public void setHidden(boolean hidden) { - try { - put(KEY_HIDDEN, hidden); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_HIDDEN); - } + put(KEY_HIDDEN, hidden); + } + + public boolean isOutgoingMessage() { + // Default is true because this field is only set from the server. + return getBoolean(KEY_INBOUND, true); } public void setCustomData(Map customData) { @@ -141,7 +108,7 @@ public void setCustomData(Map customData) { } put(KEY_CUSTOM_DATA, customDataJson); } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_CUSTOM_DATA); + ApptentiveLog.e(e, "Exception setting ApptentiveMessage's %s field.", KEY_CUSTOM_DATA); } } @@ -164,20 +131,6 @@ public void setRead(boolean read) { this.read = read; } - public String getSenderId() { - try { - if (!isNull((KEY_SENDER))) { - JSONObject sender = getJSONObject(KEY_SENDER); - if (!sender.isNull((KEY_SENDER_ID))) { - return sender.getString(KEY_SENDER_ID); - } - } - } catch (JSONException e) { - // Ignore - } - return null; - } - // For debugging only. public void setSenderId(String senderId) { try { @@ -190,7 +143,7 @@ public void setSenderId(String senderId) { } sender.put(KEY_SENDER_ID, senderId); } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_SENDER_ID); + ApptentiveLog.e(e, "Exception setting ApptentiveMessage's %s field.", KEY_SENDER_ID); } } @@ -223,22 +176,11 @@ public String getSenderProfilePhoto() { } public boolean getAutomated() { - try { - if (!isNull((KEY_AUTOMATED))) { - return getBoolean(KEY_AUTOMATED); - } - } catch (JSONException e) { - // Ignore - } - return false; + return getBoolean(KEY_AUTOMATED); } public void setAutomated(boolean isAutomated) { - try { - put(KEY_AUTOMATED, isAutomated); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ApptentiveMessage's %s field.", e, KEY_AUTOMATED); - } + put(KEY_AUTOMATED, isAutomated); } public String getDatestamp() { @@ -274,13 +216,10 @@ public boolean clearDatestamp() { } } - public abstract boolean isOutgoingMessage(); - public boolean isAutomatedMessage() { return getAutomated(); } - public enum Type { TextMessage, FileMessage, @@ -299,7 +238,7 @@ public static Type parse(String rawType) { } } - public static enum State { + public enum State { sending, // The item is either being sent, or is queued for sending. sent, // The item has been posted to the server successfully. saved, // The item has been returned from the server during a fetch. diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/CodePointStore.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/CodePointStore.java deleted file mode 100644 index acf4ffb11..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/CodePointStore.java +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import android.content.SharedPreferences; - -import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.util.Constants; -import com.apptentive.android.sdk.util.Util; - -import org.json.JSONException; -import org.json.JSONObject; - -/** - *

    All public methods altering code point values should be synchronized.

    - *

    Example:

    - *
    - * {
    - *   "code_point": {
    - *     "codePoint1": {
    - *       "last": 1234567890,
    - *       "total": 6,
    - *       "version": {
    - *         "1.1": 4,
    - *         "1.2": 2
    - *       },
    - *       "build": {
    - *         "5": 4,
    - *         "6": 2
    - *       }
    - *     }
    - *   },
    - *   "interactions": {
    - *     "526fe2836dd8bf546a00000c": {
    - *       "last": 1234567890.4,
    - *       "total": 6,
    - *       "version": {
    - *         "1.1": 4,
    - *         "1.2": 2
    - *       },
    - *       "build": {
    - *         "5": 4,
    - *         "6": 2
    - *       }
    - *     }
    - *   }
    - * }
    - * 
    - */ -public class CodePointStore { - - private JSONObject store; - - public static final String KEY_CODE_POINT = "code_point"; - public static final String KEY_INTERACTIONS = "interactions"; - public static final String KEY_LAST = "last"; // The last time this codepoint was seen. - public static final String KEY_TOTAL = "total"; // The total times this code point was seen. - public static final String KEY_VERSION_NAME = "version"; - public static final String KEY_VERSION_CODE = "build"; - - public CodePointStore() { - - } - - public void init() { - store = loadFromPreference(); - } - - private void saveToPreference() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_CODE_POINT_STORE, store.toString()).apply(); - } - - private JSONObject loadFromPreference() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String json = prefs.getString(Constants.PREF_KEY_CODE_POINT_STORE, null); - try { - if (json != null) { - return new JSONObject(json); - } - } catch (JSONException e) { - ApptentiveLog.e("Error loading CodePointStore from SharedPreferences.", e); - } - return new JSONObject(); - } - - - public synchronized void storeCodePointForCurrentAppVersion(String fullCodePoint) { - storeRecordForCurrentAppVersion(false, fullCodePoint); - } - - public synchronized void storeInteractionForCurrentAppVersion(String fullCodePoint) { - storeRecordForCurrentAppVersion(true, fullCodePoint); - } - - private void storeRecordForCurrentAppVersion(boolean isInteraction, String fullCodePoint) { - String versionName = ApptentiveInternal.getInstance().getApplicationVersionName(); - int versionCode = ApptentiveInternal.getInstance().getApplicationVersionCode(); - storeRecord(isInteraction, fullCodePoint, versionName, versionCode); - } - - public synchronized void storeRecord(boolean isInteraction, String fullCodePoint, String versionName, int versionCode) { - storeRecord(isInteraction, fullCodePoint, versionName, versionCode, Util.currentTimeSeconds()); - } - - public synchronized void storeRecord(boolean isInteraction, String fullCodePoint, String versionName, int versionCode, double currentTimeSeconds) { - String versionCodeString = String.valueOf(versionCode); - if (fullCodePoint != null && versionName != null) { - try { - String recordTypeKey = isInteraction ? CodePointStore.KEY_INTERACTIONS : CodePointStore.KEY_CODE_POINT; - JSONObject recordType; - if (!store.isNull(recordTypeKey)) { - recordType = store.getJSONObject(recordTypeKey); - } else { - recordType = new JSONObject(); - store.put(recordTypeKey, recordType); - } - - // Get or create code point object. - JSONObject codePointJson; - if (!recordType.isNull(fullCodePoint)) { - codePointJson = recordType.getJSONObject(fullCodePoint); - } else { - codePointJson = new JSONObject(); - recordType.put(fullCodePoint, codePointJson); - } - - // Set the last time this code point was seen. - codePointJson.put(KEY_LAST, currentTimeSeconds); - - // Increment the total times this code point was seen. - int total = 0; - if (codePointJson.has(KEY_TOTAL)) { - total = codePointJson.getInt(KEY_TOTAL); - } - codePointJson.put(KEY_TOTAL, total + 1); - - // Get or create versionName object. - JSONObject versionNameJson; - if (!codePointJson.isNull(KEY_VERSION_NAME)) { - versionNameJson = codePointJson.getJSONObject(KEY_VERSION_NAME); - } else { - versionNameJson = new JSONObject(); - codePointJson.put(KEY_VERSION_NAME, versionNameJson); - } - - // Set count for current versionName. - int existingVersionNameCount = 0; - if (!versionNameJson.isNull(versionName)) { - existingVersionNameCount = versionNameJson.getInt(versionName); - } - versionNameJson.put(versionName, existingVersionNameCount + 1); - - // Get or create versionCode object. - JSONObject versionCodeJson; - if (!codePointJson.isNull(KEY_VERSION_CODE)) { - versionCodeJson = codePointJson.getJSONObject(KEY_VERSION_CODE); - } else { - versionCodeJson = new JSONObject(); - codePointJson.put(KEY_VERSION_CODE, versionCodeJson); - } - - // Set count for the current versionCode - int existingVersionCodeCount = 0; - if (!versionCodeJson.isNull(versionCodeString)) { - existingVersionCodeCount = versionCodeJson.getInt(versionCodeString); - } - versionCodeJson.put(versionCodeString, existingVersionCodeCount + 1); - - saveToPreference(); - } catch (JSONException e) { - ApptentiveLog.w("Unable to store code point %s.", e, fullCodePoint); - } - } - } - - public JSONObject getRecord(boolean interaction, String name) { - String recordTypeKey = interaction ? KEY_INTERACTIONS : KEY_CODE_POINT; - try { - if (!store.isNull(recordTypeKey)) { - if (store.has(recordTypeKey)) { - JSONObject recordType = store.getJSONObject(recordTypeKey); - if (recordType.has(name)) { - return recordType.getJSONObject(name); - } - } - } - } catch (JSONException e) { - ApptentiveLog.w("Error loading code point record for \"%s\"", name); - } - return null; - } - - public Long getTotalInvokes(boolean interaction, String name) { - try { - JSONObject record = getRecord(interaction, name); - if (record != null && record.has(KEY_TOTAL)) { - return record.getLong(KEY_TOTAL); - } - } catch (JSONException e) { - // Ignore - } - return 0L; - } - - public Double getLastInvoke(boolean interaction, String name) { - try { - JSONObject record = getRecord(interaction, name); - if (record != null && record.has(KEY_LAST)) { - return record.getDouble(KEY_LAST); - } - } catch (JSONException e) { - // Ignore - } - return null; - } - - public Long getVersionNameInvokes(boolean interaction, String name, String versionName) { - try { - JSONObject record = getRecord(interaction, name); - if (record != null && record.has(KEY_VERSION_NAME)) { - JSONObject versionNameJson = record.getJSONObject(KEY_VERSION_NAME); - if (versionNameJson.has(versionName)) { - return versionNameJson.getLong(versionName); - } - } - } catch (JSONException e) { - // Ignore - } - return 0L; - } - - public Long getVersionCodeInvokes(boolean interaction, String name, String versionCode) { - try { - JSONObject record = getRecord(interaction, name); - if (record != null && record.has(KEY_VERSION_CODE)) { - JSONObject versionCodeJson = record.getJSONObject(KEY_VERSION_CODE); - if (versionCodeJson.has(versionCode)) { - return versionCodeJson.getLong(versionCode); - } - } - } catch (JSONException e) { - // Ignore - } - return 0L; - } - - public String toString() { - return "CodePointStore: " + store.toString(); - } - - public void clear() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().remove(Constants.PREF_KEY_CODE_POINT_STORE).apply(); - store = new JSONObject(); - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/CommerceExtendedData.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/CommerceExtendedData.java index ea6f4f4d2..c03db170b 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/CommerceExtendedData.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/CommerceExtendedData.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -10,9 +10,10 @@ import org.json.JSONException; import org.json.JSONObject; -/** - * @author Sky Kelsey - */ +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + public class CommerceExtendedData extends ExtendedData { private static final String KEY_ID = "id"; @@ -25,9 +26,17 @@ public class CommerceExtendedData extends ExtendedData { private static final int VERSION = 1; + private Object id; + private String affiliation; + private double revenue; + private double shipping; + private double tax; + private String currency; + private List items; @Override protected void init() { + items = new ArrayList<>(); setType(Type.commerce); setVersion(VERSION); } @@ -36,11 +45,7 @@ public CommerceExtendedData() { super(); } - public CommerceExtendedData(String json) throws JSONException { - super(json); - } - - public CommerceExtendedData(Object id, Object affiliation, Number revenue, Number shipping, Number tax, String currency) throws JSONException { + public CommerceExtendedData(Object id, String affiliation, double revenue, double shipping, double tax, String currency) throws JSONException { setId(id); setAffiliation(affiliation); setRevenue(revenue); @@ -49,33 +54,45 @@ public CommerceExtendedData(Object id, Object affiliation, Number revenue, Numbe setCurrency(currency); } + public CommerceExtendedData(String json) throws JSONException { + super(json); + JSONObject jsonObject = new JSONObject(json); + setId(jsonObject.opt(KEY_ID)); + setAffiliation(jsonObject.optString(KEY_AFFILIATION, null)); + setRevenue(jsonObject.optDouble(KEY_REVENUE, 0)); + setShipping(jsonObject.optDouble(KEY_SHIPPING, 0)); + setTax(jsonObject.optDouble(KEY_TAX, 0)); + setCurrency(jsonObject.optString(KEY_CURRENCY, null)); + setItems(jsonObject.optJSONArray(KEY_ITEMS)); + } + public CommerceExtendedData setId(Object id) throws JSONException { - put(KEY_ID, id); + this.id = id; return this; } - public CommerceExtendedData setAffiliation(Object affiliation) throws JSONException { - put(KEY_AFFILIATION, affiliation); + public CommerceExtendedData setAffiliation(String affiliation) throws JSONException { + this.affiliation = affiliation; return this; } - public CommerceExtendedData setRevenue(Number revenue) throws JSONException { - put(KEY_REVENUE, revenue); + public CommerceExtendedData setRevenue(double revenue) throws JSONException { + this.revenue = revenue; return this; } - public CommerceExtendedData setShipping(Number shipping) throws JSONException { - put(KEY_SHIPPING, shipping); + public CommerceExtendedData setShipping(double shipping) throws JSONException { + this.shipping = shipping; return this; } - public CommerceExtendedData setTax(Number tax) throws JSONException { - put(KEY_TAX, tax); + public CommerceExtendedData setTax(double tax) throws JSONException { + this.tax = tax; return this; } public CommerceExtendedData setCurrency(String currency) throws JSONException { - put(KEY_CURRENCY, currency); + this.currency = currency; return this; } @@ -87,18 +104,41 @@ public CommerceExtendedData setCurrency(String currency) throws JSONException { * @throws JSONException if item can't be added. */ public CommerceExtendedData addItem(Item item) throws JSONException { - JSONArray items; - if (isNull(KEY_ITEMS)) { - items = new JSONArray(); - put(KEY_ITEMS, items); - } else { - items = getJSONArray(KEY_ITEMS); + if (this.items == null) { + this.items = new ArrayList<>(); } - items.put(item); + items.add(item); return this; } - public static class Item extends JSONObject { + public CommerceExtendedData setItems(JSONArray items) throws JSONException { + if (items != null) { + for (int i = 0; i < items.length(); i++) { + Item item = (Item) items.get(i); + addItem(item); + } + } + return this; + } + + @Override + public JSONObject toJsonObject() throws JSONException { + JSONObject ret = super.toJsonObject(); + ret.put(KEY_ID, id); + ret.put(KEY_AFFILIATION, affiliation); + ret.put(KEY_REVENUE, revenue); + ret.put(KEY_SHIPPING, shipping); + ret.put(KEY_TAX, tax); + ret.put(KEY_CURRENCY, currency); + JSONArray itemsArray = new JSONArray(); + for (Item item : items) { + itemsArray.put(item.toJsonObject()); + } + ret.put(KEY_ITEMS, itemsArray); + return ret; + } + + public static class Item implements Serializable { private static final String KEY_ID = "id"; private static final String KEY_NAME = "name"; @@ -107,12 +147,24 @@ public static class Item extends JSONObject { private static final String KEY_QUANTITY = "quantity"; private static final String KEY_CURRENCY = "currency"; + private Object id; + private String name; + private String category; + private double price; + private double quantity; + private String currency; + public Item() { - super(); } public Item(String json) throws JSONException { - super(json); + JSONObject jsonObject = new JSONObject(json); + this.id = jsonObject.opt(KEY_ID); + this.name = jsonObject.optString(KEY_NAME, null); + this.category = jsonObject.optString(KEY_CATEGORY, null); + this.price = jsonObject.optDouble(KEY_PRICE, 0); + this.quantity = jsonObject.optDouble(KEY_QUANTITY, 0); + this.currency = jsonObject.optString(KEY_CURRENCY, null); } /** @@ -126,7 +178,7 @@ public Item(String json) throws JSONException { * @param currency The currency code for the currency used in this transaction. Ignored if null. * @throws JSONException if values cannot be set. */ - public Item(Object id, Object name, String category, Number price, Number quantity, String currency) throws JSONException { + public Item(Object id, String name, String category, double price, double quantity, String currency) throws JSONException { super(); if (id != null) { setId(id); @@ -137,45 +189,52 @@ public Item(Object id, Object name, String category, Number price, Number quanti if (category != null) { setCategory(category); } - if (price != null) { - setPrice(price); - } - if (quantity != null) { - setQuantity(quantity); - } + setPrice(price); + setQuantity(quantity); if (currency != null) { setCurrency(currency); } } public Item setId(Object id) throws JSONException { - put(KEY_ID, id); + this.id = id; return this; } - public Item setName(Object name) throws JSONException { - put(KEY_NAME, name); + public Item setName(String name) throws JSONException { + this.name = name; return this; } public Item setCategory(String category) throws JSONException { - put(KEY_CATEGORY, category); + this.category = category; return this; } - public Item setPrice(Number price) throws JSONException { - put(KEY_PRICE, price); + public Item setPrice(double price) throws JSONException { + this.price = price; return this; } - public Item setQuantity(Number quantity) throws JSONException { - put(KEY_QUANTITY, quantity); + public Item setQuantity(double quantity) throws JSONException { + this.quantity = quantity; return this; } public Item setCurrency(String currency) throws JSONException { - put(KEY_CURRENCY, currency); + this.currency = currency; return this; } + + public JSONObject toJsonObject() throws JSONException { + JSONObject ret = new JSONObject(); + ret.put(KEY_ID, id); + ret.put(KEY_NAME, name); + ret.put(KEY_CATEGORY, category); + ret.put(KEY_PRICE, price); + ret.put(KEY_QUANTITY, quantity); + ret.put(KEY_CURRENCY, currency); + return ret; + } } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/CompoundMessage.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/CompoundMessage.java new file mode 100644 index 000000000..1d4071574 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/CompoundMessage.java @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.ApptentiveInternal; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.Encryptor; +import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterUtil; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.Util; +import com.apptentive.android.sdk.util.image.ImageItem; +import com.apptentive.android.sdk.util.image.ImageUtil; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; + +public class CompoundMessage extends ApptentiveMessage implements MessageCenterUtil.CompoundMessageCommonInterface { + + private static final String KEY_BODY = "body"; + public static final String KEY_TEXT_ONLY = "text_only"; + private static final String KEY_TITLE = "title"; + private static final String KEY_ATTACHMENTS = "attachments"; + + private boolean isLast; + + private boolean hasNoAttachments = true; + + private final String boundary; + + /* For incoming message, this array stores attachment Urls + * StoredFile::apptentiveUri is set by the "url" of the remote attachment file + * StoredFile:localFilePath is set by the "thumbnail_url" of the remote attachment (maybe empty) + */ + private ArrayList remoteAttachmentStoredFiles; + + // Default constructor will only be called when the message is created from local, a.k.a outgoing + public CompoundMessage() { + super(); + boundary = UUID.randomUUID().toString(); + } + + /** + * Construct a CompoundMessage when JSON is fetched from server, or repopulated from database. + * + * @param json The message JSON + */ + public CompoundMessage(String json) throws JSONException { + super(json); + boundary = UUID.randomUUID().toString(); + parseAttachmentsArray(json); + hasNoAttachments = getTextOnly(); + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/messages", conversationId); + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + return HttpRequestMethod.POST; + } + + @Override + public String getHttpRequestContentType() { + return String.format("%s;boundary=%s", encryptionKey != null ? "multipart/encrypted" : "multipart/mixed", boundary); + } + + //endregion + + @Override + protected void initType() { + setType(Type.CompoundMessage); + } + + // Get text message body, maybe empty + @Override + public String getBody() { + return optString(KEY_BODY, null); + } + + // Set text message body, maybe empty + @Override + public void setBody(String body) { + put(KEY_BODY, body); + } + + public String getTitle() { + return optString(KEY_TITLE, null); + } + + public void setTitle(String title) { + put(KEY_TITLE, title); + } + + public boolean getTextOnly() { + return getBoolean(KEY_TEXT_ONLY); + } + + public void setTextOnly(boolean bVal) { + put(KEY_TEXT_ONLY, bVal); + } + + private List attachedFiles; + + public boolean setAssociatedImages(List attachedImages) { + + if (attachedImages == null || attachedImages.size() == 0) { + hasNoAttachments = true; + return false; + } else { + hasNoAttachments = false; + } + setTextOnly(hasNoAttachments); + ArrayList attachmentStoredFiles = new ArrayList(); + for (ImageItem image : attachedImages) { + StoredFile storedFile = new StoredFile(); + storedFile.setId(getNonce()); + storedFile.setApptentiveUri(""); + storedFile.setSourceUriOrPath(image.originalPath); + // ToDo: look for local cache + storedFile.setLocalFilePath(image.localCachePath); + storedFile.setMimeType("image/jpeg"); + storedFile.setCreationTime(image.time); + attachmentStoredFiles.add(storedFile); + } + + attachedFiles = attachmentStoredFiles; + + boolean bRet = false; + try { + Future future = ApptentiveInternal.getInstance().getApptentiveTaskManager().addCompoundMessageFiles(attachmentStoredFiles); + bRet = future.get(); + } catch (Exception e) { + ApptentiveLog.e("Unable to set associated images in worker thread"); + } finally { + return bRet; + } + } + + public boolean setAssociatedFiles(List attachedFiles) { + + this.attachedFiles = attachedFiles; + + if (attachedFiles == null || attachedFiles.size() == 0) { + hasNoAttachments = true; + return false; + } else { + hasNoAttachments = false; + } + setTextOnly(hasNoAttachments); + + boolean bRet = false; + try { + Future future = ApptentiveInternal.getInstance().getApptentiveTaskManager().addCompoundMessageFiles(attachedFiles); + bRet = future.get(); + } catch (Exception e) { + ApptentiveLog.e("Unable to set associated files in worker thread"); + } finally { + return bRet; + } + } + + public List getAssociatedFiles() { + if (hasNoAttachments) { + return null; + } + List associatedFiles = null; + try { + Future> future = ApptentiveInternal.getInstance().getApptentiveTaskManager().getAssociatedFiles(getNonce()); + associatedFiles = future.get(); + } catch (Exception e) { + ApptentiveLog.e("Unable to get associated files in worker thread"); + } finally { + return associatedFiles; + } + } + + public void deleteAssociatedFiles() { + try { + Future> future = ApptentiveInternal.getInstance().getApptentiveTaskManager().getAssociatedFiles(getNonce()); + List associatedFiles = future.get(); + // Delete local cached files + if (associatedFiles == null || associatedFiles.size() == 0) { + return; + } + + for (StoredFile file : associatedFiles) { + File localFile = new File(file.getLocalFilePath()); + localFile.delete(); + } + // Delete records from db + ApptentiveInternal.getInstance().getApptentiveTaskManager().deleteAssociatedFiles(getNonce()); + } catch (Exception e) { + ApptentiveLog.e("Unable to delete associated files in worker thread"); + } + } + + + @Override + public boolean isLastSent() { + return (isOutgoingMessage()) && isLast; + } + + @Override + public void setLastSent(boolean bVal) { + isLast = bVal; + } + + public List getRemoteAttachments() { + return remoteAttachmentStoredFiles; + } + + /* Parse attachment array in json. Only incoming compound message would have "attachments" key set + * @param messageString JSON string of the message + * @return true if attachment array is found in JSON + */ + private boolean parseAttachmentsArray(String messageString) throws JSONException { + JSONObject root = new JSONObject(messageString); + if (!root.isNull(KEY_ATTACHMENTS)) { + JSONArray items = root.getJSONArray(KEY_ATTACHMENTS); + remoteAttachmentStoredFiles = new ArrayList(); + for (int i = 0; i < items.length(); i++) { + String json = items.getJSONObject(i).toString(); + JSONObject attachment = new JSONObject(json); + String mimeType = attachment.optString("content_type"); + StoredFile storedFile = new StoredFile(); + storedFile.setId(getNonce()); + storedFile.setApptentiveUri(attachment.optString("url")); + storedFile.setSourceUriOrPath(attachment.optString("thumbnail_url")); + storedFile.setLocalFilePath(attachment.optString("")); + storedFile.setMimeType(mimeType); + storedFile.setCreationTime(0); + remoteAttachmentStoredFiles.add(storedFile); + } + if (remoteAttachmentStoredFiles.size() > 0) { + setTextOnly(false); + return true; + } + } + return false; + } + + @Override + public int getListItemType() { + if (isAutomatedMessage()) { + return MESSAGE_AUTO; + } else if (isOutgoingMessage()) { + return MESSAGE_OUTGOING; + } else { + return MESSAGE_INCOMING; + } + } + + private static final String lineEnd = "\r\n"; + private static final String twoHyphens = "--"; + + /** + * This is a multipart request. To accomplish this, we will create a data blog that is the entire contents + * of the request after the request's headers. Each part of the body includes its own headers, + * boundary, and data, but that is all rolled into one byte array to be stored pending sending. + * This enables the contents to be stores securely via encryption the moment it is created, and + * not read again as plain text while it sits on the device. + * + * @return a Byte array that can be set on the payload request. + * TODO: Refactor this API so that the resulting byte array is streamed to a file for later retrieval. + */ + @Override + public byte[] renderData() { + try { + boolean encrypted = encryptionKey != null; + Encryptor encryptor = null; + if (encrypted) { + encryptor = new Encryptor(encryptionKey); + } + ByteArrayOutputStream data = new ByteArrayOutputStream(); + + // First write the message body out as the first "part". + StringBuilder header = new StringBuilder(); + header.append(twoHyphens).append(boundary).append(lineEnd); + + StringBuilder part = new StringBuilder(); + part + .append("Content-Disposition: form-data; name=\"message\"").append(lineEnd) + .append("Content-Type: application/json;charset=UTF-8").append(lineEnd) + .append(lineEnd) + .append(marshallForSending().toString()).append(lineEnd); + byte[] partBytes = part.toString().getBytes(); + + if (encrypted) { + header + .append("Content-Disposition: form-data; name=\"message\"").append(lineEnd) + .append("Content-Type: application/octet-stream").append(lineEnd) + .append(lineEnd); + data.write(header.toString().getBytes()); + data.write(encryptor.encrypt(partBytes)); + data.write("\r\n".getBytes()); + } else { + data.write(header.toString().getBytes()); + data.write(partBytes); + } + + // Then append attachments + if (attachedFiles != null) { + for (StoredFile storedFile : attachedFiles) { + ApptentiveLog.v(PAYLOADS, "Starting to write an attachment part."); + data.write(("--" + boundary + lineEnd).getBytes()); + StringBuilder attachmentEnvelope = new StringBuilder(); + attachmentEnvelope.append(String.format("Content-Disposition: form-data; name=\"file[]\"; filename=\"%s\"", storedFile.getFileName())).append(lineEnd) + .append("Content-Type: ").append(storedFile.getMimeType()).append(lineEnd) + .append(lineEnd); + ByteArrayOutputStream attachmentBytes = new ByteArrayOutputStream(); + FileInputStream fileInputStream = null; + try { + ApptentiveLog.v(PAYLOADS, "Writing attachment envelope: %s", attachmentEnvelope.toString()); + attachmentBytes.write(attachmentEnvelope.toString().getBytes()); + + try { + if (Util.isMimeTypeImage(storedFile.getMimeType())) { + ApptentiveLog.v(PAYLOADS, "Appending image attachment."); + ImageUtil.appendScaledDownImageToStream(storedFile.getSourceUriOrPath(), attachmentBytes); + } else { + ApptentiveLog.v("Appending non-image attachment."); + Util.appendFileToStream(new File(storedFile.getSourceUriOrPath()), attachmentBytes); + } + } catch (Exception e) { + ApptentiveLog.e(PAYLOADS, "Error reading Message Payload attachment: \"%s\".", e, storedFile.getLocalFilePath()); + continue; + } finally { + Util.ensureClosed(fileInputStream); + } + + if (encrypted) { + // If encrypted, each part must be encrypted, and wrapped in a plain text set of headers. + StringBuilder encryptionEnvelope = new StringBuilder(); + encryptionEnvelope + .append("Content-Disposition: form-data; name=\"file[]\"").append(lineEnd) + .append("Content-Type: application/octet-stream").append(lineEnd) + .append(lineEnd); + ApptentiveLog.v(PAYLOADS, "Writing encrypted envelope: %s", encryptionEnvelope.toString()); + data.write(encryptionEnvelope.toString().getBytes()); + ApptentiveLog.v(PAYLOADS, "Encrypting attachment bytes: %d", attachmentBytes.size()); + byte[] encryptedAttachment = encryptor.encrypt(attachmentBytes.toByteArray()); + ApptentiveLog.v(PAYLOADS, "Writing encrypted attachment bytes: %d", encryptedAttachment.length); + data.write(encryptedAttachment); + } else { + ApptentiveLog.v(PAYLOADS, "Writing attachment bytes: %d", attachmentBytes.size()); + data.write(attachmentBytes.toByteArray()); + } + data.write("\r\n".getBytes()); + } catch (Exception e) { + ApptentiveLog.e(PAYLOADS, "Error getting data for Message Payload attachments.", e); + return null; + } + } + } + data.write(("--" + boundary + "--").getBytes()); + + ApptentiveLog.d(PAYLOADS, "Total payload body bytes: %d", data.size()); + return data.toByteArray(); + } catch (Exception e) { + ApptentiveLog.e(PAYLOADS, "Error assembling Message Payload.", e); + return null; + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/Configuration.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/Configuration.java index 1de0fe189..b5edb2f07 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/Configuration.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/Configuration.java @@ -46,12 +46,12 @@ public Configuration(String json) throws JSONException { } public void save() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); prefs.edit().putString(Constants.PREF_KEY_APP_CONFIG_JSON, toString()).apply(); } public static Configuration load() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); return Configuration.load(prefs); } @@ -62,7 +62,7 @@ public static Configuration load(SharedPreferences prefs) { return new Configuration(json); } } catch (JSONException e) { - ApptentiveLog.e("Error loading Configuration from SharedPreferences.", e); + ApptentiveLog.e(e, "Error loading Configuration from SharedPreferences."); } return new Configuration(); } @@ -167,7 +167,7 @@ public boolean isHideBranding(Context context) { Bundle metaData = ai.metaData; return metaData.getBoolean(Constants.MANIFEST_KEY_INITIALLY_HIDE_BRANDING, Constants.CONFIG_DEFAULT_HIDE_BRANDING); } catch (Exception e) { - ApptentiveLog.w("Unexpected error while reading %s manifest setting.", e, Constants.MANIFEST_KEY_INITIALLY_HIDE_BRANDING); + ApptentiveLog.w(e, "Unexpected error while reading %s manifest setting.", Constants.MANIFEST_KEY_INITIALLY_HIDE_BRANDING); } return Constants.CONFIG_DEFAULT_HIDE_BRANDING; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationItem.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationItem.java index b8e4ad766..b8b844dac 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationItem.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationItem.java @@ -1,88 +1,46 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ package com.apptentive.android.sdk.model; -import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.util.Util; -import org.json.JSONException; -import java.util.UUID; +import org.json.JSONException; -/** - * @author Sky Kelsey - */ -public abstract class ConversationItem extends Payload { +public abstract class ConversationItem extends JsonPayload { - protected static final String KEY_NONCE = "nonce"; protected static final String KEY_CLIENT_CREATED_AT = "client_created_at"; protected static final String KEY_CLIENT_CREATED_AT_UTC_OFFSET = "client_created_at_utc_offset"; - protected ConversationItem() { - super(); - setNonce(UUID.randomUUID().toString()); + protected ConversationItem(PayloadType type) { + super(type); double seconds = Util.currentTimeSeconds(); int utcOffset = Util.getUtcOffset(); setClientCreatedAt(seconds); setClientCreatedAtUtcOffset(utcOffset); - - } - - protected ConversationItem(String json) throws JSONException { - super(json); - } - - public void setNonce(String nonce) { - try { - put(KEY_NONCE, nonce); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ConversationItem's %s field.", e, KEY_NONCE); - } } - public String getNonce() { - try { - if (!isNull((KEY_NONCE))) { - return getString(KEY_NONCE); - } - } catch (JSONException e) { - // Ignore - } - return null; + protected ConversationItem(PayloadType type, String json) throws JSONException { + super(type, json); } public Double getClientCreatedAt() { - try { - return getDouble(KEY_CLIENT_CREATED_AT); - } catch (JSONException e) { - // Ignore - } - return null; + return getDouble(KEY_CLIENT_CREATED_AT); } - public void setClientCreatedAt(Double clientCreatedAt) { - try { - put(KEY_CLIENT_CREATED_AT, clientCreatedAt); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ConversationItem's %s field.", e, KEY_CLIENT_CREATED_AT); - } + public void setClientCreatedAt(double clientCreatedAt) { + put(KEY_CLIENT_CREATED_AT, clientCreatedAt); } /** * This is made public primarily so that unit tests can be made to run successfully no matter what the time zone. */ public void setClientCreatedAtUtcOffset(int clientCreatedAtUtcOffset) { - try { - put(KEY_CLIENT_CREATED_AT_UTC_OFFSET, clientCreatedAtUtcOffset); - } catch (JSONException e) { - ApptentiveLog.e("Exception setting ConversationItem's %s field.", e, KEY_CLIENT_CREATED_AT_UTC_OFFSET); - } + put(KEY_CLIENT_CREATED_AT_UTC_OFFSET, clientCreatedAtUtcOffset); } - - } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationTokenRequest.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationTokenRequest.java index 4674f82c8..630813e77 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationTokenRequest.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/ConversationTokenRequest.java @@ -7,45 +7,72 @@ package com.apptentive.android.sdk.model; import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.util.StringUtils; import org.json.JSONException; import org.json.JSONObject; -public class ConversationTokenRequest extends JSONObject { - +import java.util.Iterator; +public class ConversationTokenRequest extends JSONObject { public ConversationTokenRequest() { } - public void setDevice(Device device) { + public void setDevice(DevicePayload device) { try { - put(Device.KEY, device); + put(DevicePayload.KEY, device == null ? null : device.getJsonObject()); } catch (JSONException e) { - ApptentiveLog.e("Error adding %s to ConversationTokenRequest", Device.KEY); + ApptentiveLog.e("Error adding %s to ConversationTokenRequest", DevicePayload.KEY); } } - public void setSdk(Sdk sdk) { + public void setSdk(SdkPayload sdk) { try { - put(Sdk.KEY, sdk); + put(SdkPayload.KEY, sdk == null ? null : sdk.getJsonObject()); } catch (JSONException e) { - ApptentiveLog.e("Error adding %s to ConversationTokenRequest", Sdk.KEY); + ApptentiveLog.e("Error adding %s to ConversationTokenRequest", SdkPayload.KEY); } } - public void setPerson(Person person) { + public void setPerson(PersonPayload person) { try { - put(Person.KEY, person); + put(PersonPayload.KEY, person == null ? null : person.getJsonObject()); } catch (JSONException e) { - ApptentiveLog.e("Error adding %s to ConversationTokenRequest", Person.KEY); + ApptentiveLog.e("Error adding %s to ConversationTokenRequest", PersonPayload.KEY); } } - public void setAppRelease(AppRelease appRelease) { + public void setSdkAndAppRelease(SdkPayload sdkPayload, AppReleasePayload appReleasePayload) { + JSONObject combinedJson = new JSONObject(); + + if (sdkPayload != null) { + Iterator keys = sdkPayload.getJsonObject().keys(); + while (keys.hasNext()) { + String key = keys.next(); + try { + combinedJson.put("sdk_" + key, sdkPayload.getJsonObject().opt(key)); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + } + if (appReleasePayload != null) { + Iterator keys = appReleasePayload.getJsonObject().keys(); + while (keys.hasNext()) { + String key = keys.next(); + try { + combinedJson.put(key, appReleasePayload.getJsonObject().opt(key)); + } catch (JSONException e) { + e.printStackTrace(); + } + } + } + try { - put(appRelease.getBaseType().name(), appRelease); + put("app_release", combinedJson); } catch (JSONException e) { - ApptentiveLog.e("Error adding %s to ConversationTokenRequest", appRelease.getBaseType().name()); + e.printStackTrace(); } } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/DeviceFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/DeviceFactory.java deleted file mode 100644 index 5bec3ca79..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/DeviceFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2013, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; -import org.json.JSONException; - -/** - * @author Sky Kelsey - */ -public class DeviceFactory { - public static Device fromJson(String json) { - try { - return new Device(json); - } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Device: %s", e, json); - } catch (IllegalArgumentException e) { - // Unknown unknown #rumsfeld - } - return null; - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/DevicePayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/DevicePayload.java new file mode 100644 index 000000000..7150ccfe2 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/DevicePayload.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +public class DevicePayload extends JsonPayload { + + public static final String KEY = "device"; + + private static final String KEY_UUID = "uuid"; + private static final String KEY_OS_NAME = "os_name"; + private static final String KEY_OS_VERSION = "os_version"; + private static final String KEY_OS_BUILD = "os_build"; + private static final String KEY_OS_API_LEVEL = "os_api_level"; + private static final String KEY_MANUFACTURER = "manufacturer"; + private static final String KEY_MODEL = "model"; + private static final String KEY_BOARD = "board"; + private static final String KEY_PRODUCT = "product"; + private static final String KEY_BRAND = "brand"; + private static final String KEY_CPU = "cpu"; + private static final String KEY_DEVICE = "device"; // + private static final String KEY_CARRIER = "carrier"; + private static final String KEY_CURRENT_CARRIER = "current_carrier"; + private static final String KEY_NETWORK_TYPE = "network_type"; + private static final String KEY_BUILD_TYPE = "build_type"; + private static final String KEY_BUILD_ID = "build_id"; + private static final String KEY_BOOTLOADER_VERSION = "bootloader_version"; + private static final String KEY_RADIO_VERSION = "radio_version"; + public static final String KEY_CUSTOM_DATA = "custom_data"; + private static final String KEY_LOCALE_COUNTRY_CODE = "locale_country_code"; + private static final String KEY_LOCALE_LANGUAGE_CODE = "locale_language_code"; + private static final String KEY_LOCALE_RAW = "locale_raw"; + private static final String KEY_UTC_OFFSET = "utc_offset"; + private static final String KEY_INTEGRATION_CONFIG = "integration_config"; + + public DevicePayload() { + super(PayloadType.device); + } + + public DevicePayload(String json) throws JSONException { + super(PayloadType.device, json); + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/device", conversationId); + } + + //endregion + + public void setUuid(String uuid) { + put(KEY_UUID, uuid); + } + + public void setOsName(String osName) { + put(KEY_OS_NAME, osName); + } + + public void setOsVersion(String osVersion) { + put(KEY_OS_VERSION, osVersion); + } + + public void setOsBuild(String osBuild) { + put(KEY_OS_BUILD, osBuild); + } + + public void setOsApiLevel(String osApiLevel) { + put(KEY_OS_API_LEVEL, osApiLevel); + } + + public void setManufacturer(String manufacturer) { + put(KEY_MANUFACTURER, manufacturer); + } + + public String getModel() { + return optString(KEY_MODEL, null); + } + + public void setModel(String model) { + put(KEY_MODEL, model); + } + + public void setBoard(String board) { + put(KEY_BOARD, board); + } + + public void setProduct(String product) { + put(KEY_PRODUCT, product); + } + + public void setBrand(String brand) { + put(KEY_BRAND, brand); + } + + public void setCpu(String cpu) { + put(KEY_CPU, cpu); + } + + public String getDevice() { + return optString(KEY_DEVICE, null); + } + + public void setDevice(String device) { + put(KEY_DEVICE, device); + } + + public void setCarrier(String carrier) { + put(KEY_CARRIER, carrier); + } + + public void setCurrentCarrier(String currentCarrier) { + put(KEY_CURRENT_CARRIER, currentCarrier); + } + + public void setNetworkType(String networkType) { + put(KEY_NETWORK_TYPE, networkType); + } + + public void setBuildType(String buildType) { + put(KEY_BUILD_TYPE, buildType); + } + + public void setBuildId(String buildId) { + put(KEY_BUILD_ID, buildId); + } + + public void setBootloaderVersion(String bootloaderVersion) { + put(KEY_BOOTLOADER_VERSION, bootloaderVersion); + } + + public void setRadioVersion(String radioVersion) { + put(KEY_RADIO_VERSION, radioVersion); + } + + @SuppressWarnings("unchecked") // We check it coming in. + public CustomData getCustomData() { + if (!isNull(KEY_CUSTOM_DATA)) { + try { + return new CustomData(getJSONObject(KEY_CUSTOM_DATA)); + } catch (JSONException e) { + // Ignore + } + } + return null; + } + + public void setCustomData(CustomData customData) { + put(KEY_CUSTOM_DATA, customData); + } + + public void setIntegrationConfig(CustomData integrationConfig) { + put(KEY_INTEGRATION_CONFIG, integrationConfig); + } + + public void setLocaleCountryCode(String localeCountryCode) { + put(KEY_LOCALE_COUNTRY_CODE, localeCountryCode); + } + + public void setLocaleLanguageCode(String localeLanguageCode) { + put(KEY_LOCALE_LANGUAGE_CODE, localeLanguageCode); + } + + public void setLocaleRaw(String localeRaw) { + put(KEY_LOCALE_RAW, localeRaw); + } + + public void setUtcOffset(String utcOffset) { + put(KEY_UTC_OFFSET, utcOffset); + } + + @Override + protected String getJsonContainer() { + return "device"; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/EventFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/EventFactory.java deleted file mode 100644 index 4502e0f67..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/EventFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2013, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; - -import org.json.JSONException; - -/** - * @author Sky Kelsey - */ -public class EventFactory { - public static Event fromJson(String json) { - try { - return new Event(json); - } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Event: %s", e, json); - } catch (IllegalArgumentException e) { - // Unknown unknown #rumsfeld - } - return null; - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/EventManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/EventManager.java deleted file mode 100644 index de0bc0d5b..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/EventManager.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.storage.EventStore; - -/** - * @author Sky Kelsey - */ -public class EventManager { - - private static EventStore getEventStore() { - return ApptentiveInternal.getInstance().getApptentiveTaskManager(); - } - - public static void sendEvent(Event event) { - getEventStore().addPayload(event); - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/Event.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/EventPayload.java similarity index 67% rename from apptentive/src/main/java/com/apptentive/android/sdk/model/Event.java rename to apptentive/src/main/java/com/apptentive/android/sdk/model/EventPayload.java index 3f8c63c3e..b79280f75 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/Event.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/EventPayload.java @@ -7,6 +7,8 @@ package com.apptentive.android.sdk.model; import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; import org.json.JSONException; import org.json.JSONObject; @@ -17,7 +19,7 @@ /** * @author Sky Kelsey */ -public class Event extends ConversationItem { +public class EventPayload extends ConversationItem { private static final String KEY_LABEL = "label"; private static final String KEY_INTERACTION_ID = "interaction_id"; @@ -25,24 +27,20 @@ public class Event extends ConversationItem { private static final String KEY_TRIGGER = "trigger"; private static final String KEY_CUSTOM_DATA = "custom_data"; - public Event(String json) throws JSONException { - super(json); + public EventPayload(String json) throws JSONException { + super(PayloadType.event, json); } - public Event(String label, JSONObject data) { - super(); - try { - put(KEY_LABEL, label); - if (data != null) { - put(KEY_DATA, data); - } - } catch (JSONException e) { - ApptentiveLog.e("Unable to construct Event.", e); + public EventPayload(String label, JSONObject data) { + super(PayloadType.event); + put(KEY_LABEL, label); + if (data != null) { + put(KEY_DATA, data); } } - public Event(String label, Map data) { - super(); + public EventPayload(String label, Map data) { + super(PayloadType.event); try { put(KEY_LABEL, label); if (data != null && !data.isEmpty()) { @@ -53,12 +51,12 @@ public Event(String label, Map data) { put(KEY_DATA, dataObject); } } catch (JSONException e) { - ApptentiveLog.e("Unable to construct Event.", e); + ApptentiveLog.e(e, "Unable to construct Event."); } } - public Event(String label, String interactionId, String data, Map customData, ExtendedData... extendedData) { - super(); + public EventPayload(String label, String interactionId, String data, Map customData, ExtendedData... extendedData) { + super(PayloadType.event); try { put(KEY_LABEL, label); if (interactionId != null) { @@ -76,15 +74,19 @@ public Event(String label, String interactionId, String data, Map customData) { JSONObject ret = new JSONObject(); for (String key : customData.keySet()) { @@ -100,18 +102,33 @@ private JSONObject generateCustomDataJson(Map customData) { return ret; } - public Event(String label, String trigger) { + public EventPayload(String label, String trigger) { this(label, (Map) null); - Map data = new HashMap(); + Map data = new HashMap<>(); data.put(KEY_TRIGGER, trigger); putData(data); } @Override - protected void initBaseType() { - setBaseType(BaseType.event); + protected String getJsonContainer() { + return "event"; + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/events", conversationId); + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + return HttpRequestMethod.POST; } + + //endregion + public void putData(Map data) { if (data == null || data.isEmpty()) { return; @@ -128,7 +145,7 @@ public void putData(Map data) { dataObject.put(key, data.get(key)); } } catch (JSONException e) { - ApptentiveLog.e("Unable to add data to Event.", e); + ApptentiveLog.e(e, "Unable to add data to Event."); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/ExtendedData.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/ExtendedData.java index cd27c73e5..ea2bcfba8 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/ExtendedData.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/ExtendedData.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -11,21 +11,23 @@ import org.json.JSONException; import org.json.JSONObject; -/** - * @author Sky Kelsey - */ -public abstract class ExtendedData extends JSONObject { +import java.io.Serializable; + +public abstract class ExtendedData implements Serializable { private static final String KEY_VERSION = "version"; private Type type = Type.unknown; + private int version; + protected ExtendedData() { init(); } protected ExtendedData(String json) throws JSONException { - super(json); + JSONObject jsonObject = new JSONObject(json); + setVersion(jsonObject.optInt(KEY_VERSION, -1)); init(); } @@ -38,12 +40,13 @@ protected void setType(Type type) { } protected void setVersion(int version) { - try { - put(KEY_VERSION, version); - } catch (JSONException e) { - ApptentiveLog.w("Error adding %s to ExtendedData.", KEY_VERSION, e); - } - return; + this.version = version; + } + + public JSONObject toJsonObject() throws JSONException { + JSONObject ret = new JSONObject(); + ret.put(KEY_VERSION, version); + return ret; } protected abstract void init(); @@ -58,7 +61,7 @@ public static Type parse(String type) { try { return Type.valueOf(type); } catch (IllegalArgumentException e) { - ApptentiveLog.v("Error parsing unknown ExtendedData.BaseType: " + type); + ApptentiveLog.v("Error parsing unknown ExtendedData.PayloadType: " + type); } return unknown; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/JsonPayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/JsonPayload.java new file mode 100644 index 000000000..1986e2178 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/JsonPayload.java @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.Encryptor; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.UUID; + +import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; + +public abstract class JsonPayload extends Payload { + + private static final String KEY_NONCE = "nonce"; + + private final JSONObject jsonObject; + + public JsonPayload(PayloadType type) { + super(type); + jsonObject = new JSONObject(); + setNonce(UUID.randomUUID().toString()); + } + + public JsonPayload(PayloadType type, String json) throws JSONException { + super(type); + jsonObject = new JSONObject(json); + } + + //region Data + + @Override + public byte[] renderData() throws JSONException { + String jsonString = marshallForSending().toString(); + ApptentiveLog.vv(PAYLOADS, jsonString); + + if (encryptionKey != null) { + byte[] bytes = jsonString.getBytes(); + Encryptor encryptor = new Encryptor(encryptionKey); + try { + return encryptor.encrypt(bytes); + } catch (Exception e) { + ApptentiveLog.e(PAYLOADS, "Error encrypting payload data", e); + } + return null; + } else { + return jsonString.getBytes(); + } + } + + //endregion + + //region Json + + protected void put(String key, String value) { + try { + jsonObject.put(key, value); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while putting json pair '%s'='%s'", key, value); + } + } + + protected void put(String key, boolean value) { + try { + jsonObject.put(key, value); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while putting json pair '%s'='%s'", key, value); + } + } + + protected void put(String key, int value) { + try { + jsonObject.put(key, value); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while putting json pair '%s'='%s'", key, value); + } + } + + protected void put(String key, double value) { + try { + jsonObject.put(key, value); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while putting json pair '%s'='%s'", key, value); + } + } + + protected void put(String key, JSONObject object) { + try { + jsonObject.put(key, object); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while putting json pair '%s'='%s'", key, object); + } + } + + protected void remove(String key) { // TODO: rename to removeKey + jsonObject.remove(key); + } + + public String optString(String key, String fallback) { + if (!jsonObject.isNull(key)) { + return jsonObject.optString(key, fallback); + } + return null; + } + + public int optInt(String key, int defaultValue) { + return jsonObject.optInt(key, defaultValue); + } + + public boolean getBoolean(String key) { + return getBoolean(key, false); + } + + public boolean getBoolean(String key, boolean defaultValue) { + return jsonObject.optBoolean(key, defaultValue); + } + + protected Double getDouble(String key) { + try { + return jsonObject.getDouble(key); + } catch (Exception e) { + // Ignore. + } + return null; + } + + protected double getDouble(String key, double defaultValue) { + return jsonObject.optDouble(key, defaultValue); + } + + protected JSONObject getJSONObject(String key) { + return jsonObject.optJSONObject(key); + } + + protected boolean isNull(String key) { // TODO: rename to containsKey + return jsonObject.isNull(key); + } + + //endregion + + //region String Representation + + @Override + public String toString() { + return StringUtils.format("%s %s", getClass().getSimpleName(), jsonObject); + } + + //endregion + + //region Getters/Setters + + public JSONObject getJsonObject() { + return jsonObject; + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + return HttpRequestMethod.PUT; + } + + @Override + public String getHttpRequestContentType() { + if (encryptionKey != null) { + return "application/octet-stream"; + } else { + return "application/json"; + } + } + + @Override + public String getNonce() { + return optString(KEY_NONCE, null); + } + + @Override + public void setNonce(String nonce) { + put(KEY_NONCE, nonce); + } + + //endregion + + protected final JSONObject marshallForSending() throws JSONException { + JSONObject result; + String container = getJsonContainer(); + if (container != null) { + result = new JSONObject(); + result.put(container, jsonObject); + } else { + result = jsonObject; + } + + if (encryptionKey != null) { + result.put("token", token); + } + + return result; + } + + protected String getJsonContainer() { + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/LocationExtendedData.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/LocationExtendedData.java index 1274830e8..d978ee235 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/LocationExtendedData.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/LocationExtendedData.java @@ -1,22 +1,22 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ package com.apptentive.android.sdk.model; -import com.apptentive.android.sdk.ApptentiveLog; import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; -/** - * @author Sky Kelsey - */ public class LocationExtendedData extends ExtendedData { private static final String KEY_COORDINATES = "coordinates"; + private double longitude; + private double latitude; + private static final int VERSION = 1; @Override @@ -25,23 +25,32 @@ protected void init() { setVersion(VERSION); } - public LocationExtendedData(String json) throws JSONException { - super(json); - } - public LocationExtendedData(double longitude, double latitude) { super(); setCoordinates(longitude, latitude); } - public void setCoordinates(double longitude, double latitude) { - try { - JSONArray coordinates = new JSONArray(); - put(KEY_COORDINATES, coordinates); - coordinates.put(0, longitude); - coordinates.put(1, latitude); - } catch (JSONException e) { - ApptentiveLog.w("Error adding %s to LocationExtendedData.", KEY_COORDINATES, e); + public LocationExtendedData(String json) throws JSONException { + super(json); + JSONObject jsonObject = new JSONObject(json); + JSONArray coords = jsonObject.optJSONArray(KEY_COORDINATES); + if (coords != null) { + setCoordinates(coords.optDouble(0, 0), coords.optDouble(1, 0)); } } + + public void setCoordinates(double longitude, double latitude) { + this.longitude = longitude; + this.latitude = latitude; + } + + @Override + public JSONObject toJsonObject() throws JSONException { + JSONObject ret = super.toJsonObject(); + JSONArray coordinates = new JSONArray(); + ret.put(KEY_COORDINATES, coordinates); + coordinates.put(longitude); + coordinates.put(latitude); + return ret; + } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/LogoutPayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/LogoutPayload.java new file mode 100644 index 000000000..50969cf31 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/LogoutPayload.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; + +public class LogoutPayload extends JsonPayload { + public LogoutPayload() { + super(PayloadType.logout); + } + + public LogoutPayload(String json) throws JSONException { + super(PayloadType.logout, json); + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/session", conversationId); + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + return HttpRequestMethod.DELETE; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/Payload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/Payload.java index d6b4494c6..33b24b5e8 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/Payload.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/Payload.java @@ -1,99 +1,126 @@ /* - * Copyright (c) 2013, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ package com.apptentive.android.sdk.model; -import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; import org.json.JSONException; -import org.json.JSONObject; -/** - * @author Sky Kelsey - */ -public abstract class Payload extends JSONObject { +import java.util.List; - // These two are not stored in the JSON, only the DB. - private Long databaseId; - private BaseType baseType; +public abstract class Payload { + private final PayloadType payloadType; - public Payload() { - initBaseType(); - } + /** + * If set, this payload should be encrypted in renderData(). + */ + protected String encryptionKey; + + /** + * The Conversation ID of the payload, if known at this time. + */ + protected String conversationId; + + /** + * Encrypted Payloads need to include the Conversation JWT inside them so that the server can + * authenticate each payload after it is decrypted. + */ + protected String token; - public Payload(String json) throws JSONException { - super(json); - initBaseType(); + private String localConversationIdentifier; + + private List attachments; // TODO: Figure out attachment handling + + protected Payload(PayloadType type) { + if (type == null) { + throw new IllegalArgumentException("Payload type is null"); + } + + this.payloadType = type; } + //region Data + /** - * Each subclass must set its type in this method. + * Binary data to be stored in database */ - protected abstract void initBaseType(); + public abstract byte[] renderData() throws JSONException; + + //region + + //region Http-request /** - * Subclasses should override this method if there is any peculiarity in how they present or wrap json before sending. - * - * @return A wrapper object containing the name of the object type, the value of which is the contents of this Object. + * Http endpoint for sending this payload */ - public String marshallForSending() { - JSONObject wrapper = new JSONObject(); - try { - wrapper.put(getBaseType().name(), this); - } catch (JSONException e) { - ApptentiveLog.w("Error wrapping Payload in JSONObject.", e); - return null; - } - return wrapper.toString(); + public abstract String getHttpEndPoint(String conversationId); + + /** + * Http request method for sending this payload + */ + public abstract HttpRequestMethod getHttpRequestMethod(); + + /** + * Http content type for sending this payload + */ + public abstract String getHttpRequestContentType(); + + //endregion + + //region Getters/Setters + + public PayloadType getPayloadType() { + return payloadType; } - public long getDatabaseId() { - return databaseId; + public void setEncryptionKey(String encryptionKey) { + this.encryptionKey = encryptionKey; } - public void setDatabaseId(long databaseId) { - this.databaseId = databaseId; + public boolean hasEncryptionKey() { + return !StringUtils.isNullOrEmpty(encryptionKey); } - public BaseType getBaseType() { - return baseType; + public String getConversationId() { + return conversationId; } - protected void setBaseType(BaseType baseType) { - this.baseType = baseType; + public void setConversationId(String conversationId) { + this.conversationId = conversationId; } - public static enum BaseType { - message, - event, - device, - sdk, - app_release, - person, - unknown, - // Legacy - survey; - - public static BaseType parse(String type) { - try { - return BaseType.valueOf(type); - } catch (IllegalArgumentException e) { - ApptentiveLog.v("Error parsing unknown Payload.BaseType: " + type); - } - return unknown; - } + public String getToken() { + return token; + } + public void setToken(String token) { + this.token = token; } - /** - * @deprecated Do not use this method to check for key existence. Instead us !isNull(KEY_NAME), as this works better - * with keys with null values. - */ - @Override - public boolean has(String key) { - return super.has(key); + public abstract String getNonce(); + + public abstract void setNonce(String nonce); + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; } + + public String getLocalConversationIdentifier() { + return localConversationIdentifier; + } + + public void setLocalConversationIdentifier(String localConversationIdentifier) { + this.localConversationIdentifier = localConversationIdentifier; + } + + //endregion } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadData.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadData.java new file mode 100644 index 000000000..176dfb7cf --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadData.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; + +public class PayloadData { + private final PayloadType type; + private final String nonce; + private final String conversationId; + private final byte[] data; + private final String authToken; + private final String contentType; + private final String httpRequestPath; + private final HttpRequestMethod httpRequestMethod; + private final boolean encrypted; + + + public PayloadData(PayloadType type, String nonce, String conversationId, byte[] data, String authToken, String contentType, String httpRequestPath, HttpRequestMethod httpRequestMethod, boolean encrypted) { + if (type == null) { + throw new IllegalArgumentException("Payload type is null"); + } + + if (nonce == null) { + throw new IllegalArgumentException("Nonce is null"); + } + + if (conversationId == null) { + throw new IllegalArgumentException("Conversation ID is null"); + } + + if (data == null) { + throw new IllegalArgumentException("Data is null"); + } + + if (contentType == null) { + throw new IllegalArgumentException("Content type is null"); + } + + if (httpRequestPath == null) { + throw new IllegalArgumentException("Path is null"); + } + + if (httpRequestMethod == null) { + throw new IllegalArgumentException("Http request method is null"); + } + + this.type = type; + this.nonce = nonce; + this.conversationId = conversationId; + this.data = data; + this.authToken = authToken; + this.contentType = contentType; + this.httpRequestPath = httpRequestPath; + this.httpRequestMethod = httpRequestMethod; + this.encrypted = encrypted; + } + + //region String representation + + @Override + public String toString() { + return StringUtils.format("type=%s nonce=%s conversationId=%s authToken=%s httpRequestPath=%s", type, nonce, conversationId, authToken, httpRequestPath); + } + + //endregion + + //region Getters + + public PayloadType getType() { + return type; + } + + public String getNonce() { + return nonce; + } + + public String getConversationId() { + return conversationId; + } + + public byte[] getData() { + return data; + } + + public String getAuthToken() { + return authToken; + } + + public String getContentType() { + return contentType; + } + + public String getHttpRequestPath() { + return httpRequestPath; + } + + public HttpRequestMethod getHttpRequestMethod() { + return httpRequestMethod; + } + + public boolean isEncrypted() { + return encrypted; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadFactory.java deleted file mode 100644 index c39f58ecb..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadFactory.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; - -import org.json.JSONException; - -/** - * @author Sky Kelsey - */ -public class PayloadFactory { - - public static Payload fromJson(String json, Payload.BaseType baseType) { - switch (baseType) { - case message: - return MessageFactory.fromJson(json); - case event: - return EventFactory.fromJson(json); - case device: - return DeviceFactory.fromJson(json); - case sdk: - return SdkFactory.fromJson(json); - case app_release: - return AppReleaseFactory.fromJson(json); - case person: - return PersonFactory.fromJson(json); - case survey: - try { - return new SurveyResponse(json); - } catch (JSONException e) { - // Ignore - } - case unknown: - ApptentiveLog.v("Ignoring unknown RecordType."); - break; - default: - break; - } - return null; - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadType.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadType.java new file mode 100644 index 000000000..e9146daed --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadType.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.ApptentiveLog; + +public enum PayloadType { + message, + event, + device, + sdk, + app_release, + sdk_and_app_release, + person, + logout, + unknown, + // Legacy + survey; + + public static PayloadType parse(String type) { + try { + return PayloadType.valueOf(type); + } catch (IllegalArgumentException e) { + ApptentiveLog.v("Error parsing unknown Payload.PayloadType: " + type); + } + return unknown; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/PersonFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/PersonFactory.java deleted file mode 100644 index fb40f0029..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/PersonFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (c) 2013, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; - -import org.json.JSONException; - -/** - * @author Sky Kelsey - */ -public class PersonFactory { - public static Person fromJson(String json) { - try { - return new Person(json); - } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Person: %s", e, json); - } catch (IllegalArgumentException e) { - // Unknown unknown #rumsfeld - } - return null; - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/PersonPayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/PersonPayload.java new file mode 100644 index 000000000..f1b0e7537 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/PersonPayload.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +public class PersonPayload extends JsonPayload { + + public static final String KEY = "person"; + + private static final String KEY_ID = "id"; + private static final String KEY_EMAIL = "email"; + private static final String KEY_NAME = "name"; + private static final String KEY_FACEBOOK_ID = "facebook_id"; + private static final String KEY_PHONE_NUMBER = "phone_number"; + private static final String KEY_STREET = "street"; + private static final String KEY_CITY = "city"; + private static final String KEY_ZIP = "zip"; + private static final String KEY_COUNTRY = "country"; + private static final String KEY_BIRTHDAY = "birthday"; + public static final String KEY_CUSTOM_DATA = "custom_data"; + + public PersonPayload() { + super(PayloadType.person); + } + + public PersonPayload(String json) throws JSONException { + super(PayloadType.person, json); + } + + //region Http-request + + + @Override + protected String getJsonContainer() { + return KEY; + } + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/person", conversationId); + } + + //endregion + + public String getId() { + return optString(KEY_ID, null); + } + + public void setId(String id) { + put(KEY_ID, id); + } + + public String getEmail() { + return optString(KEY_EMAIL, null); + } + + public void setEmail(String email) { + put(KEY_EMAIL, email); + } + + public String getName() { + return optString(KEY_NAME, null); + } + + public void setName(String name) { + put(KEY_NAME, name); + } + + public void setFacebookId(String facebookId) { + put(KEY_FACEBOOK_ID, facebookId); + } + + public void setPhoneNumber(String phoneNumber) { + put(KEY_PHONE_NUMBER, phoneNumber); + } + + public void setStreet(String street) { + put(KEY_STREET, street); + } + + public void setCity(String city) { + put(KEY_CITY, city); + } + + public void setZip(String zip) { + put(KEY_ZIP, zip); + } + + public void setCountry(String country) { + put(KEY_COUNTRY, country); + } + + public void setBirthday(String birthday) { + put(KEY_BIRTHDAY, birthday); + } + + @SuppressWarnings("unchecked") // We check it coming in. + public CustomData getCustomData() { + if (!isNull(KEY_CUSTOM_DATA)) { + try { + return new CustomData(getJSONObject(KEY_CUSTOM_DATA)); + } catch (JSONException e) { + // Ignore + } + } + return null; + } + + public void setCustomData(CustomData customData) { + put(KEY_CUSTOM_DATA, customData); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkAndAppReleasePayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkAndAppReleasePayload.java new file mode 100644 index 000000000..fc1b7e110 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkAndAppReleasePayload.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; + +import com.apptentive.android.sdk.ApptentiveInternal; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.Util; + +/** + * A combined payload of {@link SdkPayload} and {@link AppReleasePayload} payloads. + *

    + * This class effectively contains the source code from both {@link SdkPayload} + * and {@link AppReleasePayload} payloads (which still kept for backward compatibility + * purposes). + */ +public class SdkAndAppReleasePayload extends JsonPayload { + + private static final String KEY_TYPE = "type"; + private static final String KEY_VERSION_NAME = "version_name"; + private static final String KEY_VERSION_CODE = "version_code"; + private static final String KEY_IDENTIFIER = "identifier"; + private static final String KEY_TARGET_SDK_VERSION = "target_sdk_version"; + private static final String KEY_APP_STORE = "app_store"; + private static final String KEY_STYLE_INHERIT = "inheriting_styles"; + private static final String KEY_STYLE_OVERRIDE = "overriding_styles"; + private static final String KEY_DEBUG = "debug"; + + private static final String KEY_SDK_VERSION = "sdk_version"; + private static final String KEY_SDK_PROGRAMMING_LANGUAGE = "sdk_programming_language"; + private static final String KEY_SDK_AUTHOR_NAME = "sdk_author_name"; + private static final String KEY_SDK_AUTHOR_EMAIL = "sdk_author_email"; + private static final String KEY_SDK_PLATFORM = "sdk_platform"; + private static final String KEY_SDK_DISTRIBUTION = "sdk_distribution"; + private static final String KEY_SDK_DISTRIBUTION_VERSION = "sdk_distribution_version"; + + public SdkAndAppReleasePayload() { + super(PayloadType.sdk_and_app_release); + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/app_release", conversationId); + } + + //endregion + + //region Sdk getters/setters + public String getVersion() { + return optString(KEY_SDK_VERSION, null); + } + + public void setVersion(String version) { + put(KEY_SDK_VERSION, version); + } + + public String getProgrammingLanguage() { + return optString(KEY_SDK_PROGRAMMING_LANGUAGE, null); + } + + public void setProgrammingLanguage(String programmingLanguage) { + put(KEY_SDK_PROGRAMMING_LANGUAGE, programmingLanguage); + } + + public String getAuthorName() { + return optString(KEY_SDK_AUTHOR_NAME, null); + } + + public void setAuthorName(String authorName) { + put(KEY_SDK_AUTHOR_NAME, authorName); + } + + public String getAuthorEmail() { + return optString(KEY_SDK_AUTHOR_EMAIL, null); + } + + public void setAuthorEmail(String authorEmail) { + put(KEY_SDK_AUTHOR_EMAIL, authorEmail); + } + + public String getPlatform() { + return optString(KEY_SDK_PLATFORM, null); + } + + public void setPlatform(String platform) { + put(KEY_SDK_PLATFORM, platform); + } + + public String getDistribution() { + return optString(KEY_SDK_DISTRIBUTION, null); + } + + public void setDistribution(String distribution) { + put(KEY_SDK_DISTRIBUTION, distribution); + } + + public String getDistributionVersion() { + return optString(KEY_SDK_DISTRIBUTION_VERSION, null); + } + + public void setDistributionVersion(String distributionVersion) { + put(KEY_SDK_DISTRIBUTION_VERSION, distributionVersion); + } + //endregion + + //region AppRelease getters/setters + + public String getType() { + return optString(KEY_TYPE, null); + } + + public void setType(String type) { + put(KEY_TYPE, type); + } + + public String getVersionName() { + return optString(KEY_VERSION_NAME, null); + } + + public void setVersionName(String versionName) { + put(KEY_VERSION_NAME, versionName); + } + + public int getVersionCode() { + return optInt(KEY_VERSION_CODE, -1); + } + + public void setVersionCode(int versionCode) { + put(KEY_VERSION_CODE, versionCode); + } + + public String getIdentifier() { + return optString(KEY_IDENTIFIER, null); + } + + public void setIdentifier(String identifier) { + put(KEY_IDENTIFIER, identifier); + } + + public String getTargetSdkVersion() { + return optString(KEY_TARGET_SDK_VERSION, null); + } + + public void setTargetSdkVersion(String targetSdkVersion) { + put(KEY_TARGET_SDK_VERSION, targetSdkVersion); + } + + public String getAppStore() { + return optString(KEY_APP_STORE, null); + } + + public void setAppStore(String appStore) { + put(KEY_APP_STORE, appStore); + } + + // Flag for whether the apptentive is inheriting styles from the host app + public boolean getInheritStyle() { + return getBoolean(KEY_STYLE_INHERIT); + } + + public void setInheritStyle(boolean inheritStyle) { + put(KEY_STYLE_INHERIT, inheritStyle); + } + + // Flag for whether the app is overriding any Apptentive Styles + public boolean getOverrideStyle() { + return getBoolean(KEY_STYLE_OVERRIDE); + } + + public void setOverrideStyle(boolean overrideStyle) { + put(KEY_STYLE_OVERRIDE, overrideStyle); + } + + public boolean getDebug() { + return getBoolean(KEY_DEBUG); + } + + public void setDebug(boolean debug) { + put(KEY_DEBUG, debug); + } + + public static AppReleasePayload generateCurrentAppRelease(Context context) { + + AppReleasePayload appRelease = new AppReleasePayload(); + + String appPackageName = context.getPackageName(); + PackageManager packageManager = context.getPackageManager(); + + int currentVersionCode = 0; + String currentVersionName = "0"; + int targetSdkVersion = 0; + boolean isAppDebuggable = false; + try { + PackageInfo packageInfo = packageManager.getPackageInfo(appPackageName, PackageManager.GET_META_DATA | PackageManager.GET_RECEIVERS); + ApplicationInfo ai = packageInfo.applicationInfo; + currentVersionCode = packageInfo.versionCode; + currentVersionName = packageInfo.versionName; + targetSdkVersion = packageInfo.applicationInfo.targetSdkVersion; + Bundle metaData = ai.metaData; + if (metaData != null) { + isAppDebuggable = (ai.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + } catch (PackageManager.NameNotFoundException e) { + ApptentiveLog.e("Failed to read app's PackageInfo."); + } + + int themeOverrideResId = context.getResources().getIdentifier("ApptentiveThemeOverride", "style", appPackageName); + + appRelease.setType("android"); + appRelease.setVersionName(currentVersionName); + appRelease.setIdentifier(appPackageName); + appRelease.setVersionCode(currentVersionCode); + appRelease.setTargetSdkVersion(String.valueOf(targetSdkVersion)); + appRelease.setAppStore(Util.getInstallerPackageName(context)); + appRelease.setInheritStyle(ApptentiveInternal.getInstance().isAppUsingAppCompatTheme()); + appRelease.setOverrideStyle(themeOverrideResId != 0); + appRelease.setDebug(isAppDebuggable); + + return appRelease; + } + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkFactory.java deleted file mode 100644 index f2aa02f7f..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkFactory.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2013, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; -import org.json.JSONException; - -/** - * @author Sky Kelsey - */ -public class SdkFactory { - public static Sdk fromJson(String json) { - try { - return new Sdk(json); - } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Sdk: %s", e, json); - } catch (IllegalArgumentException e) { - // Unknown unknown #rumsfeld - } - return null; - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkPayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkPayload.java new file mode 100644 index 000000000..b1215c195 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/SdkPayload.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.network.HttpRequestMethod; + +import org.json.JSONException; + +public class SdkPayload extends JsonPayload { + + public static final String KEY = "sdk"; + + private static final String KEY_VERSION = "version"; + private static final String KEY_PROGRAMMING_LANGUAGE = "programming_language"; + private static final String KEY_AUTHOR_NAME = "author_name"; + private static final String KEY_AUTHOR_EMAIL = "author_email"; + private static final String KEY_PLATFORM = "platform"; + private static final String KEY_DISTRIBUTION = "distribution"; + private static final String KEY_DISTRIBUTION_VERSION = "distribution_version"; + + public SdkPayload() { + super(PayloadType.sdk); + } + + public SdkPayload(String json) throws JSONException { + super(PayloadType.sdk, json); + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + throw new RuntimeException(getClass().getName() + " is deprecated"); // FIXME: find a better approach + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + throw new RuntimeException(getClass().getName() + " is deprecated"); // FIXME: find a better approach + } + + @Override + public String getHttpRequestContentType() { + throw new RuntimeException(getClass().getName() + " is deprecated"); // FIXME: find a better approach + } + + //endregion + + public String getVersion() { + return optString(KEY_VERSION, null); + } + + public void setVersion(String version) { + put(KEY_VERSION, version); + } + + public String getProgrammingLanguage() { + return optString(KEY_PROGRAMMING_LANGUAGE, null); + } + + public void setProgrammingLanguage(String programmingLanguage) { + put(KEY_PROGRAMMING_LANGUAGE, programmingLanguage); + } + + public String getAuthorName() { + return optString(KEY_AUTHOR_NAME, null); + } + + public void setAuthorName(String authorName) { + put(KEY_AUTHOR_NAME, authorName); + } + + public String getAuthorEmail() { + return optString(KEY_AUTHOR_EMAIL, null); + } + + public void setAuthorEmail(String authorEmail) { + put(KEY_AUTHOR_EMAIL, authorEmail); + } + + public String getPlatform() { + return optString(KEY_PLATFORM, null); + } + + public void setPlatform(String platform) { + put(KEY_PLATFORM, platform); + } + + public String getDistribution() { + return optString(KEY_DISTRIBUTION, null); + } + + public void setDistribution(String distribution) { + put(KEY_DISTRIBUTION, distribution); + } + + public String getDistributionVersion() { + return optString(KEY_DISTRIBUTION_VERSION, null); + } + + public void setDistributionVersion(String distributionVersion) { + put(KEY_DISTRIBUTION_VERSION, distributionVersion); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/SurveyResponse.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/SurveyResponse.java deleted file mode 100644 index 8506d9141..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/SurveyResponse.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.model; - -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.module.engagement.interaction.model.SurveyInteraction; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.Map; - -public class SurveyResponse extends ConversationItem { - - private static final String KEY_SURVEY_ID = "id"; - - private static final String KEY_SURVEY_ANSWERS = "answers"; - - public SurveyResponse(String json) throws JSONException { - super(json); - } - - public SurveyResponse(SurveyInteraction definition, Map answers) { - super(); - - try { - put(KEY_SURVEY_ID, definition.getId()); - JSONObject answersJson = new JSONObject(); - for (String key : answers.keySet()) { - answersJson.put(key, answers.get(key)); - } - - put(KEY_SURVEY_ANSWERS, answersJson); - } catch (JSONException e) { - ApptentiveLog.e("Unable to construct survey payload.", e); - } - } - - public String getId() { - return optString(KEY_SURVEY_ID, ""); - } - - @Override - protected void initBaseType() { - setBaseType(BaseType.survey); - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/SurveyResponsePayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/SurveyResponsePayload.java new file mode 100644 index 000000000..3b7f97366 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/SurveyResponsePayload.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.model; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.module.engagement.interaction.model.SurveyInteraction; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Map; + +public class SurveyResponsePayload extends ConversationItem { + + private static final String KEY_RESPONSE = "response"; + + private static final String KEY_SURVEY_ID = "id"; + + private static final String KEY_SURVEY_ANSWERS = "answers"; + + public SurveyResponsePayload(SurveyInteraction definition, Map answers) { + super(PayloadType.survey); + try { + put(KEY_SURVEY_ID, definition.getId()); + JSONObject answersJson = new JSONObject(); + for (String key : answers.keySet()) { + answersJson.put(key, answers.get(key)); + } + + put(KEY_SURVEY_ANSWERS, answersJson); + } catch (JSONException e) { + ApptentiveLog.e(e, "Unable to construct survey payload."); + } + } + + public SurveyResponsePayload(String json) throws JSONException { + super(PayloadType.survey, json); + } + + @Override + protected String getJsonContainer() { + return KEY_RESPONSE; + } + + //region Http-request + + @Override + public String getHttpEndPoint(String conversationId) { + return StringUtils.format("/conversations/%s/surveys/%s/responses", conversationId, getId()); + } + + @Override + public HttpRequestMethod getHttpRequestMethod() { + return HttpRequestMethod.POST; + } + + //endregion + + public String getId() { + return optString(KEY_SURVEY_ID, null); + } + +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/model/TimeExtendedData.java b/apptentive/src/main/java/com/apptentive/android/sdk/model/TimeExtendedData.java index 2f98759d2..bde0fecf8 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/TimeExtendedData.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/TimeExtendedData.java @@ -1,25 +1,24 @@ /* - * Copyright (c) 2014, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ package com.apptentive.android.sdk.model; -import com.apptentive.android.sdk.ApptentiveLog; import org.json.JSONException; +import org.json.JSONObject; import java.util.Date; -/** - * @author Sky Kelsey - */ public class TimeExtendedData extends ExtendedData { private static final String KEY_TIMESTAMP = "timestamp"; private static final int VERSION = 1; + private double timestamp; + @Override protected void init() { setType(Type.time); @@ -63,10 +62,13 @@ protected void setTimestamp(long millis) { } protected void setTimestamp(double dateInSeconds) { - try { - put(KEY_TIMESTAMP, dateInSeconds); - } catch (JSONException e) { - ApptentiveLog.w("Error adding %s to TimeExtendedData.", KEY_TIMESTAMP, e); - } + this.timestamp = dateInSeconds; + } + + @Override + public JSONObject toJsonObject() throws JSONException { + JSONObject ret = super.toJsonObject(); + ret.put(KEY_TIMESTAMP, timestamp); + return ret; } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java index 2d518cf05..f60256990 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/EngagementModule.java @@ -13,13 +13,15 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.ApptentiveViewActivity; -import com.apptentive.android.sdk.model.Event; -import com.apptentive.android.sdk.model.EventManager; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.model.EventPayload; import com.apptentive.android.sdk.model.ExtendedData; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.module.engagement.interaction.model.MessageCenterInteraction; import com.apptentive.android.sdk.module.metric.MetricModule; import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.Util; import java.util.Map; @@ -28,43 +30,58 @@ */ public class EngagementModule { - public static synchronized boolean engageInternal(Context context, String eventName) { - return engage(context, "com.apptentive", "app", null, eventName, null, null, (ExtendedData[]) null); + public static synchronized boolean engageInternal(Context context, Conversation conversation, String eventName) { + return engage(context, conversation, "com.apptentive", "app", null, eventName, null, null, (ExtendedData[]) null); } - public static synchronized boolean engageInternal(Context context, String eventName, String data) { - return engage(context, "com.apptentive", "app", null, eventName, data, null, (ExtendedData[]) null); + public static synchronized boolean engageInternal(Context context, Conversation conversation, String eventName, String data) { + return engage(context, conversation, "com.apptentive", "app", null, eventName, data, null, (ExtendedData[]) null); } - public static synchronized boolean engageInternal(Context context, Interaction interaction, String eventName) { - return engage(context, "com.apptentive", interaction.getType().name(), interaction.getId(), eventName, null, null, (ExtendedData[]) null); + public static synchronized boolean engageInternal(Context context, Conversation conversation, Interaction interaction, String eventName) { + return engage(context, conversation, "com.apptentive", interaction.getType().name(), interaction.getId(), eventName, null, null, (ExtendedData[]) null); } - public static synchronized boolean engageInternal(Context context, Interaction interaction, String eventName, String data) { - return engage(context, "com.apptentive", interaction.getType().name(), interaction.getId(), eventName, data, null, (ExtendedData[]) null); + public static synchronized boolean engageInternal(Context context, Conversation conversation, Interaction interaction, String eventName, String data) { + return engage(context, conversation, "com.apptentive", interaction.getType().name(), interaction.getId(), eventName, data, null, (ExtendedData[]) null); } - public static synchronized boolean engage(Context context, String vendor, String interaction, String interactionId, String eventName, String data, Map customData, ExtendedData... extendedData) { - if (!ApptentiveInternal.isApptentiveRegistered() || context == null) { + public static synchronized boolean engage(Context context, Conversation conversation, String vendor, String interaction, String interactionId, String eventName, String data, Map customData, ExtendedData... extendedData) { + if (context == null) { + throw new IllegalArgumentException("Context is null"); + } + + if (conversation == null) { + throw new IllegalArgumentException("Conversation is null"); + } + + Assert.assertTrue(ApptentiveInternal.isApptentiveRegistered()); + if (!ApptentiveInternal.isApptentiveRegistered()) { return false; } + try { String eventLabel = generateEventLabel(vendor, interaction, eventName); ApptentiveLog.d("engage(%s)", eventLabel); - ApptentiveInternal.getInstance().getCodePointStore().storeCodePointForCurrentAppVersion(eventLabel); - EventManager.sendEvent(new Event(eventLabel, interactionId, data, customData, extendedData)); - return doEngage(context, eventLabel); + String versionName = ApptentiveInternal.getInstance().getApplicationVersionName(); + int versionCode = ApptentiveInternal.getInstance().getApplicationVersionCode(); + conversation.getEventData().storeEventForCurrentAppVersion(Util.currentTimeSeconds(), versionCode, versionName, eventLabel); + conversation.addPayload(new EventPayload(eventLabel, interactionId, data, customData, extendedData)); + return doEngage(conversation, context, eventLabel); } catch (Exception e) { + ApptentiveLog.w(e, "Error in engage()"); MetricModule.sendError(e, null, null); } return false; } - public static boolean doEngage(Context context, String eventLabel) { - Interaction interaction = ApptentiveInternal.getInstance().getInteractionManager().getApplicableInteraction(eventLabel); + private static boolean doEngage(Conversation conversation, Context context, String eventLabel) { + Interaction interaction = conversation.getApplicableInteraction(eventLabel); if (interaction != null) { - ApptentiveInternal.getInstance().getCodePointStore().storeInteractionForCurrentAppVersion(interaction.getId()); + String versionName = ApptentiveInternal.getInstance().getApplicationVersionName(); + int versionCode = ApptentiveInternal.getInstance().getApplicationVersionCode(); + conversation.getEventData().storeInteractionForCurrentAppVersion(Util.currentTimeSeconds(), versionCode, versionName, interaction.getId()); launchInteraction(context, interaction); return true; } @@ -108,13 +125,17 @@ public static void launchMessageCenterErrorActivity(Context context) { } } - public static boolean canShowInteraction(String vendor, String interaction, String eventName) { + public static boolean canShowInteraction(Conversation conversation, String interaction, String eventName, String vendor) { String eventLabel = generateEventLabel(vendor, interaction, eventName); - return canShowInteraction(eventLabel); + return canShowInteraction(conversation, eventLabel); } - private static boolean canShowInteraction(String eventLabel) { - Interaction interaction = ApptentiveInternal.getInstance().getInteractionManager().getApplicableInteraction(eventLabel); + private static boolean canShowInteraction(Conversation conversation, String eventLabel) { + if (conversation == null) { + throw new IllegalArgumentException("Conversation is null"); + } + + Interaction interaction = conversation.getApplicableInteraction(eventLabel); return interaction != null; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/InteractionManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/InteractionManager.java index 3791189d3..bfafdb1e0 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/InteractionManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/InteractionManager.java @@ -6,237 +6,10 @@ package com.apptentive.android.sdk.module.engagement.interaction; -import android.content.SharedPreferences; -import android.os.AsyncTask; -import android.os.Build; - -import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.comm.ApptentiveClient; -import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; -import com.apptentive.android.sdk.module.engagement.interaction.model.Interactions; -import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; -import com.apptentive.android.sdk.module.engagement.interaction.model.InteractionsPayload; -import com.apptentive.android.sdk.module.engagement.interaction.model.Targets; -import com.apptentive.android.sdk.module.metric.MetricModule; -import com.apptentive.android.sdk.util.Constants; -import com.apptentive.android.sdk.util.Util; - -import org.json.JSONException; - -import java.util.concurrent.atomic.AtomicBoolean; - public class InteractionManager { - private Interactions interactions; - private Targets targets; - private Boolean pollForInteractions; - // boolean to prevent multiple fetching threads - private AtomicBoolean isFetchPending = new AtomicBoolean(false); - public interface InteractionUpdateListener { void onInteractionUpdated(boolean successful); } - public Interactions getInteractions() { - if (interactions == null) { - interactions = loadInteractions(); - } - return interactions; - } - - public Targets getTargets() { - if (targets == null) { - targets = loadTargets(); - } - return targets; - } - - public Interaction getApplicableInteraction(String eventLabel) { - - Targets targets = getTargets(); - - if (targets != null) { - String interactionId = targets.getApplicableInteraction(eventLabel); - if (interactionId != null) { - Interactions interactions = getInteractions(); - return interactions.getInteraction(interactionId); - } - } - return null; - } - - public void asyncFetchAndStoreInteractions() { - - if (!isPollForInteractions()) { - ApptentiveLog.v("Interaction polling is disabled."); - return; - } - - boolean force = ApptentiveInternal.getInstance().isApptentiveDebuggable(); - // Check isFetchPending to only allow one asyncTask at a time when fetching interaction - if (isFetchPending.compareAndSet(false, true) && (force || hasCacheExpired())) { - AsyncTask task = new AsyncTask() { - // Hold onto the exception from the AsyncTask instance for later handling in UI thread - private Exception e = null; - - @Override - protected Boolean doInBackground(Void... params) { - try { - return fetchAndStoreInteractions(); - } catch (Exception e) { - this.e = e; - } - return false; - } - - @Override - protected void onPostExecute(Boolean successful) { - isFetchPending.set(false); - if (e == null) { - ApptentiveLog.d("Fetching new Interactions asyncTask finished. Successful? %b", successful); - // Update pending state on UI thread after finishing the task - ApptentiveInternal.getInstance().notifyInteractionUpdated(successful); - } else { - ApptentiveLog.w("Unhandled Exception thrown from fetching new Interactions asyncTask", e); - MetricModule.sendError(e, null, null); - } - } - }; - - ApptentiveLog.i("Fetching new Interactions asyncTask scheduled"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } else { - task.execute(); - } - } else { - ApptentiveLog.v("Using cached Interactions."); - } - } - - // This method will be run from a worker thread created by asyncTask - private boolean fetchAndStoreInteractions() { - ApptentiveLog.i("Fetching new Interactions asyncTask started"); - ApptentiveHttpResponse response = ApptentiveClient.getInteractions(); - - // We weren't able to connect to the internet. - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - boolean updateSuccessful = true; - if (response.isException()) { - prefs.edit().putBoolean(Constants.PREF_KEY_MESSAGE_CENTER_SERVER_ERROR_LAST_ATTEMPT, false).apply(); - updateSuccessful = false; - } - // We got a server error. - else if (!response.isSuccessful()) { - prefs.edit().putBoolean(Constants.PREF_KEY_MESSAGE_CENTER_SERVER_ERROR_LAST_ATTEMPT, true).apply(); - updateSuccessful = false; - } - - if (updateSuccessful) { - String interactionsPayloadString = response.getContent(); - - // Store new integration cache expiration. - String cacheControl = response.getHeaders().get("Cache-Control"); - Integer cacheSeconds = Util.parseCacheControlHeader(cacheControl); - if (cacheSeconds == null) { - cacheSeconds = Constants.CONFIG_DEFAULT_INTERACTION_CACHE_EXPIRATION_DURATION_SECONDS; - } - updateCacheExpiration(cacheSeconds); - storeInteractionsPayloadString(interactionsPayloadString); - } - - return updateSuccessful; - } - - /** - * Made public for testing. There is no other reason to use this method directly. - */ - public void storeInteractionsPayloadString(String interactionsPayloadString) { - try { - InteractionsPayload payload = new InteractionsPayload(interactionsPayloadString); - Interactions interactions = payload.getInteractions(); - Targets targets = payload.getTargets(); - if (interactions != null && targets != null) { - this.interactions = interactions; - this.targets = targets; - saveInteractions(); - saveTargets(); - } else { - ApptentiveLog.e("Unable to save payloads."); - } - } catch (JSONException e) { - ApptentiveLog.w("Invalid InteractionsPayload received."); - } - } - - public void clear() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().remove(Constants.PREF_KEY_INTERACTIONS).apply(); - prefs.edit().remove(Constants.PREF_KEY_TARGETS).apply(); - interactions = null; - targets = null; - } - - private void saveInteractions() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_INTERACTIONS, interactions.toString()).apply(); - } - - private Interactions loadInteractions() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String interactionsString = prefs.getString(Constants.PREF_KEY_INTERACTIONS, null); - if (interactionsString != null) { - try { - return new Interactions(interactionsString); - } catch (JSONException e) { - ApptentiveLog.w("Exception creating Interactions object.", e); - } - } - return null; - } - - private void saveTargets() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_TARGETS, targets.toString()).apply(); - } - - private Targets loadTargets() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String targetsString = prefs.getString(Constants.PREF_KEY_TARGETS, null); - if (targetsString != null) { - try { - return new Targets(targetsString); - } catch (JSONException e) { - ApptentiveLog.w("Exception creating Targets object.", e); - } - } - return null; - } - - private boolean hasCacheExpired() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - long expiration = prefs.getLong(Constants.PREF_KEY_INTERACTIONS_PAYLOAD_CACHE_EXPIRATION, 0); - return expiration < System.currentTimeMillis(); - } - - public void updateCacheExpiration(long duration) { - long expiration = System.currentTimeMillis() + (duration * 1000); - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putLong(Constants.PREF_KEY_INTERACTIONS_PAYLOAD_CACHE_EXPIRATION, expiration).apply(); - } - - public boolean isPollForInteractions() { - if (pollForInteractions == null) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - pollForInteractions = prefs.getBoolean(Constants.PREF_KEY_POLL_FOR_INTERACTIONS, true); - } - return pollForInteractions; - } - - public void setPollForInteractions(boolean pollForInteractions) { - this.pollForInteractions = pollForInteractions; - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putBoolean(Constants.PREF_KEY_POLL_FOR_INTERACTIONS, pollForInteractions).apply(); - } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/AboutFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/AboutFragment.java index 5ed344ba2..67cda3029 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/AboutFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/AboutFragment.java @@ -17,7 +17,6 @@ import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.model.ExtendedData; -import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; @@ -64,7 +63,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa close.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - EngagementModule.engage(getActivity(), "com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CLOSE, null, null, (ExtendedData[]) null); + engage("com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CLOSE, null, null, (ExtendedData[]) null); transit(); } }); @@ -96,17 +95,17 @@ public void onClick(View view) { public boolean onFragmentExit(ApptentiveViewExitType exitType) { if (exitType.equals(ApptentiveViewExitType.BACK_BUTTON)) { - EngagementModule.engage(getActivity(), "com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CANCEL, null, null, (ExtendedData[]) null); + engage("com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CANCEL, null, null, (ExtendedData[]) null); } else if (exitType.equals(ApptentiveViewExitType.NOTIFICATION)) { - EngagementModule.engage(getActivity(), "com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CANCEL, exitTypeToDataJson(exitType), null, (ExtendedData[]) null); + engage("com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CANCEL, exitTypeToDataJson(exitType), null, (ExtendedData[]) null); } else { - EngagementModule.engage(getActivity(), "com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CLOSE, null, null, (ExtendedData[]) null); + engage("com.apptentive", INTERACTION_NAME, null, EVENT_NAME_CLOSE, exitTypeToDataJson(exitType), null, (ExtendedData[]) null); } return false; } @Override protected void sendLaunchEvent(Activity activity) { - EngagementModule.engage(getActivity(), "com.apptentive", INTERACTION_NAME, null, EVENT_NAME_LAUNCH, null, null, (ExtendedData[]) null); + engage("com.apptentive", INTERACTION_NAME, null, EVENT_NAME_LAUNCH, null, null, (ExtendedData[]) null); } } \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/ApptentiveBaseFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/ApptentiveBaseFragment.java index 0b15d3000..6c8aa1b54 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/ApptentiveBaseFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/ApptentiveBaseFragment.java @@ -10,7 +10,6 @@ import android.annotation.TargetApi; import android.app.Activity; import android.content.Context; - import android.content.res.Resources; import android.graphics.PorterDuff; import android.graphics.drawable.ColorDrawable; @@ -36,6 +35,8 @@ import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.model.ExtendedData; import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.InteractionManager; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; @@ -47,6 +48,9 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; +import java.util.Map; + +import static com.apptentive.android.sdk.debug.Assert.assertNotNull; public abstract class ApptentiveBaseFragment extends DialogFragment implements InteractionManager.InteractionUpdateListener { @@ -72,6 +76,7 @@ public abstract class ApptentiveBaseFragment extends Dial protected boolean hasLaunched; protected String sectionTitle; + private Conversation conversation; private OnFragmentTransitionListener onTransitionListener; public interface OnFragmentTransitionListener { @@ -149,7 +154,7 @@ public void onAttach(Context context) { updateHosts(getFragmentManager(), (FragmentHostCallback) hostField.get(getFragmentManager())); } catch (Exception e) { - ApptentiveLog.w(e.getMessage(), e); + ApptentiveLog.w(e, e.getMessage()); } } else { //If the child fragment manager has not been retained yet @@ -174,7 +179,7 @@ private void updateHosts(FragmentManager fragmentManager, FragmentHostCallback c mHostField.setAccessible(true); mHostField.set(fragment, currentHost); } catch (Exception e) { - ApptentiveLog.w(e.getMessage(), e); + ApptentiveLog.w(e, e.getMessage()); } if (fragment.getChildFragmentManager() != null) { updateHosts(fragment.getChildFragmentManager(), currentHost); @@ -248,7 +253,7 @@ public void onCreate(Bundle savedInstanceState) { */ protected void sendLaunchEvent(Activity activity) { if (interaction != null) { - EngagementModule.engageInternal(activity, interaction, EVENT_NAME_LAUNCH); + engageInternal(EVENT_NAME_LAUNCH); } } @@ -514,4 +519,32 @@ public static void removeFragment(FragmentManager fragmentManager, Fragment frag fragmentManager.beginTransaction().remove(fragment).commit(); } + //region Helpers + + public boolean engage(String vendor, String interaction, String interactionId, String eventName, String data, Map customData, ExtendedData... extendedData) { + Conversation conversation = getConversation(); + assertNotNull(conversation, "Attempted to engage '%s' event without an active conversation", eventName); + return conversation != null && EngagementModule.engage(getActivity(), conversation, vendor, interaction, interactionId, eventName, data, customData, extendedData); + } + + public boolean engageInternal(String eventName) { + return engageInternal(eventName, null); + } + + public boolean engageInternal(String eventName, String data) { + Conversation conversation = getConversation(); + assertNotNull(conversation, "Attempted to engage '%s' event without an active conversation", eventName); + return conversation != null && EngagementModule.engageInternal(getActivity(), conversation, interaction, eventName, data); + } + + protected Conversation getConversation() { + return conversation; + } + + public void setConversation(Conversation conversation) { + this.conversation = conversation; + } + + //endregion + } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/EnjoymentDialogFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/EnjoymentDialogFragment.java index 32c76c373..44ce2e375 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/EnjoymentDialogFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/EnjoymentDialogFragment.java @@ -16,7 +16,6 @@ import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; -import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.EnjoymentDialogInteraction; public class EnjoymentDialogFragment extends ApptentiveBaseFragment implements View.OnClickListener { @@ -73,7 +72,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa @Override public boolean onFragmentExit(ApptentiveViewExitType exitType) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_CANCEL, exitTypeToDataJson(exitType)); + engageInternal(CODE_POINT_CANCEL, exitTypeToDataJson(exitType)); return false; } @@ -81,11 +80,11 @@ public boolean onFragmentExit(ApptentiveViewExitType exitType) { public void onClick(View v) { int id = v.getId(); if (id == R.id.yes) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_YES); + engageInternal(CODE_POINT_YES); } else if (id == R.id.no) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_NO); + engageInternal(CODE_POINT_NO); } else if (id == R.id.dismiss) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_DISMISS); + engageInternal(CODE_POINT_DISMISS); } transit(); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterErrorFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterErrorFragment.java index 88e8b502a..60f246793 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterErrorFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterErrorFragment.java @@ -21,7 +21,6 @@ import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.model.ExtendedData; -import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; @@ -45,9 +44,9 @@ public static MessageCenterErrorFragment newInstance(Bundle bundle) { @Override protected void sendLaunchEvent(Activity activity) { if (wasLastAttemptServerError(getContext()) || Util.isNetworkConnectionPresent()) { - EngagementModule.engage(getActivity(), "com.apptentive", "MessageCenter", null, EVENT_NAME_NO_INTERACTION_ATTEMPTING, null, null, (ExtendedData[]) null); + engage("com.apptentive", "MessageCenter", null, EVENT_NAME_NO_INTERACTION_ATTEMPTING, null, null, (ExtendedData[]) null); } else { - EngagementModule.engage(getActivity(), "com.apptentive", "MessageCenter", null, EVENT_NAME_NO_INTERACTION_NO_INTERNET, null, null, (ExtendedData[]) null); + engage("com.apptentive", "MessageCenter", null, EVENT_NAME_NO_INTERACTION_NO_INTERNET, null, null, (ExtendedData[]) null); } } @@ -84,7 +83,7 @@ private boolean wasLastAttemptServerError(Context context) { public boolean onFragmentExit(ApptentiveViewExitType exitType) { - EngagementModule.engage(getActivity(), "com.apptentive", "MessageCenter", null, EVENT_NAME_NO_INTERACTION_CLOSE, exitTypeToDataJson(exitType), null, (ExtendedData[]) null); + engage("com.apptentive", "MessageCenter", null, EVENT_NAME_NO_INTERACTION_CLOSE, exitTypeToDataJson(exitType), null, (ExtendedData[]) null); return false; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterFragment.java index 438562cd6..c98d98aa4 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/MessageCenterFragment.java @@ -42,13 +42,12 @@ import com.apptentive.android.sdk.ApptentiveViewActivity; import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; -import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; -import com.apptentive.android.sdk.module.engagement.EngagementModule; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.module.engagement.interaction.model.MessageCenterInteraction; import com.apptentive.android.sdk.module.messagecenter.MessageManager; import com.apptentive.android.sdk.module.messagecenter.OnListviewItemActionListener; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; import com.apptentive.android.sdk.module.messagecenter.model.ContextMessage; import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterListItem; import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterStatus; @@ -58,7 +57,6 @@ import com.apptentive.android.sdk.module.messagecenter.view.MessageCenterRecyclerView; import com.apptentive.android.sdk.module.messagecenter.view.MessageCenterRecyclerViewAdapter; import com.apptentive.android.sdk.module.messagecenter.view.holder.MessageComposerHolder; -import com.apptentive.android.sdk.module.metric.MetricModule; import com.apptentive.android.sdk.util.AnimationUtil; import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; @@ -254,7 +252,6 @@ public void onStart() { public void onStop() { super.onStop(); - clearPendingMessageCenterPushNotification(); ApptentiveInternal.getInstance().getMessageManager().setMessageCenterInForeground(false); } @@ -286,7 +283,7 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) { } } - EngagementModule.engageInternal(getActivity(), interaction, MessageCenterInteraction.EVENT_NAME_ATTACH); + engageInternal(MessageCenterInteraction.EVENT_NAME_ATTACH); String originalPath = Util.getRealFilePathFromUri(hostingActivity, uri); if (originalPath != null) { @@ -326,11 +323,11 @@ public void onResume() { ApptentiveInternal.getInstance().getMessageManager().resumeSending(); /* imagePickerStillOpen was set true when the picker intent was launched. If user had picked an image, - * it woud have been set to false. Otherwise, it indicates the user tried to attach an image but + * it would have been set to false. Otherwise, it indicates the user tried to attach an image but * abandoned the image picker without picking anything */ if (imagePickerStillOpen) { - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_ATTACHMENT_CANCEL); + engageInternal(MessageCenterInteraction.EVENT_NAME_ATTACHMENT_CANCEL); imagePickerStillOpen = false; } } @@ -435,11 +432,13 @@ else if (pendingWhoCardName != null || pendingWhoCardEmail != null || pendingWho } // Retrieve any saved attachments - final SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - if (prefs.contains(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS)) { + final SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); + Conversation conversation = getConversation(); + if (conversation != null && conversation.getMessageCenterPendingAttachments() != null) { + String pendingAttachmentsString = conversation.getMessageCenterPendingAttachments(); JSONArray savedAttachmentsJsonArray = null; try { - savedAttachmentsJsonArray = new JSONArray(prefs.getString(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS, "")); + savedAttachmentsJsonArray = new JSONArray(pendingAttachmentsString); } catch (JSONException e) { e.printStackTrace(); } @@ -461,8 +460,7 @@ else if (pendingWhoCardName != null || pendingWhoCardEmail != null || pendingWho } } // Stored pending attachments have been restored, remove it from the persistent storage - SharedPreferences.Editor editor = prefs.edit(); - editor.remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS).apply(); + conversation.setMessageCenterPendingAttachments(null); } updateMessageSentStates(); } @@ -478,7 +476,7 @@ public boolean onMenuItemClick(MenuItem menuItem) { } catch (JSONException e) { // } - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_PROFILE_OPEN, data.toString()); + engageInternal(MessageCenterInteraction.EVENT_NAME_PROFILE_OPEN, data.toString()); boolean whoCardDisplayedBefore = wasWhoCardAsPreviouslyDisplayed(); forceShowKeyboard = true; @@ -516,18 +514,17 @@ public boolean onFragmentExit(ApptentiveViewExitType exitType) { } cleanup(); if (exitType.equals(ApptentiveViewExitType.BACK_BUTTON)) { - EngagementModule.engageInternal(hostingActivity, interaction, MessageCenterInteraction.EVENT_NAME_CANCEL); + engageInternal(MessageCenterInteraction.EVENT_NAME_CANCEL); } else if (exitType.equals(ApptentiveViewExitType.NOTIFICATION)) { - EngagementModule.engageInternal(hostingActivity, interaction, MessageCenterInteraction.EVENT_NAME_CANCEL, exitTypeToDataJson(exitType)); + engageInternal(MessageCenterInteraction.EVENT_NAME_CANCEL, exitTypeToDataJson(exitType)); } else { - EngagementModule.engageInternal(hostingActivity, interaction, MessageCenterInteraction.EVENT_NAME_CLOSE); + engageInternal(MessageCenterInteraction.EVENT_NAME_CLOSE, exitTypeToDataJson(exitType)); } } return false; } public boolean cleanup() { - clearPendingMessageCenterPushNotification(); // Set to null, otherwise they will hold reference to the activity context MessageManager mgr = ApptentiveInternal.getInstance().getMessageManager(); @@ -539,30 +536,6 @@ public boolean cleanup() { return true; } - - private void clearPendingMessageCenterPushNotification() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String pushData = prefs.getString(Constants.PREF_KEY_PENDING_PUSH_NOTIFICATION, null); - if (pushData != null) { - try { - JSONObject pushJson = new JSONObject(pushData); - ApptentiveInternal.PushAction action = ApptentiveInternal.PushAction.unknown; - if (pushJson.has(ApptentiveInternal.PUSH_ACTION)) { - action = ApptentiveInternal.PushAction.parse(pushJson.getString(ApptentiveInternal.PUSH_ACTION)); - } - switch (action) { - case pmc: - ApptentiveLog.i("Clearing pending Message Center push notification."); - prefs.edit().remove(Constants.PREF_KEY_PENDING_PUSH_NOTIFICATION).apply(); - break; - } - } catch (JSONException e) { - ApptentiveLog.w("Error parsing JSON from push notification.", e); - MetricModule.sendError(e, "Parsing Push notification", pushData); - } - } - } - public void addComposingCard() { hideFab(); hideProfileButton(); @@ -598,7 +571,7 @@ private boolean checkAddWhoCardIfRequired() { } catch (JSONException e) { // } - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_PROFILE_OPEN, data.toString()); + engageInternal(MessageCenterInteraction.EVENT_NAME_PROFILE_OPEN, data.toString()); return true; } return false; @@ -704,7 +677,7 @@ public void setAttachmentsInComposer(final List images) { } public void removeImageFromComposer(final int position) { - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_ATTACHMENT_DELETE); + engageInternal(MessageCenterInteraction.EVENT_NAME_ATTACHMENT_DELETE); messagingActionHandler.sendMessage(messagingActionHandler.obtainMessage(MSG_REMOVE_ATTACHMENT, position, 0)); messagingActionHandler.sendEmptyMessageDelayed(MSG_SCROLL_TO_BOTTOM, DEFAULT_DELAYMILLIS); } @@ -720,7 +693,7 @@ public void openNonImageAttachment(final ImageItem image) { ApptentiveLog.d("Cannot open file attachment"); } } catch (Exception e) { - ApptentiveLog.e("Error loading attachment", e); + ApptentiveLog.e(e, "Error loading attachment"); } } @@ -744,17 +717,19 @@ public void showAttachmentDialog(final ImageItem image) { dialog.show(ft, DIALOG_IMAGE_PREVIEW); } catch (Exception e) { - ApptentiveLog.e("Error loading attachment preview.", e); + ApptentiveLog.e(e, "Error loading attachment preview."); } } @SuppressWarnings("unchecked") // We should never get a message passed in that is not appropriate for the view it goes into. - public synchronized void onMessageSent(ApptentiveHttpResponse response, final ApptentiveMessage apptentiveMessage) { - if (response.isSuccessful() || response.isRejectedPermanently() || response.isBadPayload()) { - messagingActionHandler.sendMessage(messagingActionHandler.obtainMessage(MSG_MESSAGE_SENT, - apptentiveMessage)); + public synchronized void onMessageSent(int responseCode, final ApptentiveMessage apptentiveMessage) { + final boolean isRejectedPermanently = responseCode >= 400 && responseCode < 500; + final boolean isSuccessful = responseCode >= 200 && responseCode < 300; + + if (isSuccessful || isRejectedPermanently || responseCode == -1) { + messagingActionHandler.sendMessage(messagingActionHandler.obtainMessage(MSG_MESSAGE_SENT, apptentiveMessage)); } } @@ -771,25 +746,26 @@ public void onComposingViewCreated(MessageComposerHolder composer, final EditTex this.composer = composer; this.composerEditText = composerEditText; - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + Conversation conversation = getConversation(); // Restore composing text editing state, such as cursor position, after rotation if (composingViewSavedState != null) { if (this.composerEditText != null) { this.composerEditText.onRestoreInstanceState(composingViewSavedState); } composingViewSavedState = null; - SharedPreferences.Editor editor = prefs.edit(); - editor.remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE).apply(); + // Stored pending composing text has been restored from the saved state, so it's not needed here anymore + if (conversation != null) { + conversation.setMessageCenterPendingMessage(null); + } } // Restore composing text - if (prefs.contains(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE)) { - String messageText = prefs.getString(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE, null); + else if (conversation != null && !TextUtils.isEmpty(conversation.getMessageCenterPendingMessage())) { + String messageText = conversation.getMessageCenterPendingMessage(); if (messageText != null && this.composerEditText != null) { this.composerEditText.setText(messageText); } // Stored pending composing text has been restored, remove it from the persistent storage - SharedPreferences.Editor editor = prefs.edit(); - editor.remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE).apply(); + conversation.setMessageCenterPendingMessage(null); } setAttachmentsInComposer(pendingAttachments); @@ -871,7 +847,7 @@ public void onCancelComposing() { } catch (JSONException e) { // } - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_COMPOSE_CLOSE, data.toString()); + engageInternal(MessageCenterInteraction.EVENT_NAME_COMPOSE_CLOSE, data.toString()); messagingActionHandler.sendMessage(messagingActionHandler.obtainMessage(MSG_REMOVE_COMPOSER)); if (messageCenterRecyclerViewAdapter != null) { addExpectationStatusIfNeeded(); @@ -897,6 +873,11 @@ public void onFinishComposing() { compoundMessage.setCustomData(ApptentiveInternal.getInstance().getAndClearCustomData()); compoundMessage.setAssociatedImages(new ArrayList(pendingAttachments)); + Conversation conversation = getConversation(); + if (conversation != null && conversation.hasActiveState()) { + compoundMessage.setSenderId(conversation.getPerson().getId()); + } + messagingActionHandler.sendMessage(messagingActionHandler.obtainMessage(MSG_START_SENDING, compoundMessage)); composingViewSavedState = null; composerEditText.getText().clear(); @@ -916,7 +897,7 @@ public void onSubmitWhoCard(String buttonLabel) { } catch (JSONException e) { // } - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_PROFILE_SUBMIT, data.toString()); + engageInternal(MessageCenterInteraction.EVENT_NAME_PROFILE_SUBMIT, data.toString()); setWhoCardAsPreviouslyDisplayed(); cleanupWhoCard(); @@ -938,7 +919,7 @@ public void onCloseWhoCard(String buttonLabel) { } catch (JSONException e) { // } - EngagementModule.engageInternal(hostingActivityRef.get(), interaction, MessageCenterInteraction.EVENT_NAME_PROFILE_CLOSE, data.toString()); + engageInternal(MessageCenterInteraction.EVENT_NAME_PROFILE_CLOSE, data.toString()); setWhoCardAsPreviouslyDisplayed(); cleanupWhoCard(); @@ -1018,15 +999,19 @@ public void onAttachImage() { } private void setWhoCardAsPreviouslyDisplayed() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - SharedPreferences.Editor editor = prefs.edit(); - editor.putBoolean(Constants.PREF_KEY_MESSAGE_CENTER_WHO_CARD_DISPLAYED_BEFORE, true); - editor.apply(); + Conversation conversation = getConversation(); + if (conversation == null) { + return; + } + conversation.setMessageCenterWhoCardPreviouslyDisplayed(true); } private boolean wasWhoCardAsPreviouslyDisplayed() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - return prefs.getBoolean(Constants.PREF_KEY_MESSAGE_CENTER_WHO_CARD_DISPLAYED_BEFORE, false); + Conversation conversation = getConversation(); + if (conversation == null) { + return false; + } + return conversation.isMessageCenterWhoCardPreviouslyDisplayed(); } // Retrieve the content from the composing area @@ -1037,14 +1022,17 @@ public Editable getPendingComposingContent() { public void savePendingComposingMessage() { Editable content = getPendingComposingContent(); - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); + SharedPreferences prefs = ApptentiveInternal.getInstance().getGlobalSharedPrefs(); SharedPreferences.Editor editor = prefs.edit(); + Conversation conversation = getConversation(); + if (conversation == null) { + return; + } if (content != null) { - editor.putString(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE, content.toString().trim()); + conversation.setMessageCenterPendingMessage(content.toString().trim()); } else { - editor.remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE); + conversation.setMessageCenterPendingMessage(null); } - JSONArray pendingAttachmentsJsonArray = new JSONArray(); // Save pending attachment for (ImageItem pendingAttachment : pendingAttachments) { @@ -1052,10 +1040,9 @@ public void savePendingComposingMessage() { } if (pendingAttachmentsJsonArray.length() > 0) { - editor.putString(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS, pendingAttachmentsJsonArray.toString()); + conversation.setMessageCenterPendingAttachments(pendingAttachmentsJsonArray.toString()); } else { - editor.remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS); - editor.remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS); + conversation.setMessageCenterPendingAttachments(null); } editor.apply(); } @@ -1064,12 +1051,11 @@ public void savePendingComposingMessage() { * will clear the pending composing message previously saved in shared preference */ public void clearPendingComposingMessage() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit() - .remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE) - .remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS) - .remove(Constants.PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS) - .apply(); + Conversation conversation = getConversation(); + if (conversation != null) { + conversation.setMessageCenterPendingMessage(null); + conversation.setMessageCenterPendingAttachments(null); + } } private Parcelable saveEditTextInstanceState() { @@ -1312,7 +1298,7 @@ public void handleMessage(Message msg) { break; } case MSG_MESSAGE_ADD_COMPOSING: { - EngagementModule.engageInternal(fragment.hostingActivityRef.get(), fragment.interaction, MessageCenterInteraction.EVENT_NAME_COMPOSE_OPEN); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_COMPOSE_OPEN); fragment.listItems.add(fragment.interaction.getComposer()); fragment.messageCenterRecyclerViewAdapter.notifyItemInserted(fragment.listItems.size() - 1); fragment.messageCenterRecyclerView.setSelection(fragment.listItems.size() - 1); @@ -1388,7 +1374,7 @@ public void handleMessage(Message msg) { } catch (JSONException e) { // } - EngagementModule.engageInternal(fragment.hostingActivityRef.get(), fragment.interaction, MessageCenterInteraction.EVENT_NAME_PROFILE_OPEN, data.toString()); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_PROFILE_OPEN, data.toString()); fragment.forceShowKeyboard = true; fragment.addWhoCard(true); } @@ -1468,7 +1454,7 @@ public void handleMessage(Message msg) { if (createdTime != null && createdTime > Double.MIN_VALUE) { MessageCenterStatus status = fragment.interaction.getRegularStatus(); if (status != null) { - EngagementModule.engageInternal(fragment.hostingActivityRef.get(), fragment.interaction, MessageCenterInteraction.EVENT_NAME_STATUS); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_STATUS); // Add expectation status message if the last is a sent listItems.add(status); fragment.messageCenterRecyclerViewAdapter.notifyItemInserted(listItems.size() - 1); @@ -1506,13 +1492,13 @@ public void handleMessage(Message msg) { MessageCenterStatus status = null; if (reason == MessageManager.SEND_PAUSE_REASON_NETWORK) { status = fragment.interaction.getErrorStatusNetwork(); - EngagementModule.engageInternal(fragment.hostingActivityRef.get(), fragment.interaction, MessageCenterInteraction.EVENT_NAME_MESSAGE_NETWORK_ERROR); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_MESSAGE_NETWORK_ERROR); } else if (reason == MessageManager.SEND_PAUSE_REASON_SERVER) { status = fragment.interaction.getErrorStatusServer(); - EngagementModule.engageInternal(fragment.hostingActivityRef.get(), fragment.interaction, MessageCenterInteraction.EVENT_NAME_MESSAGE_HTTP_ERROR); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_MESSAGE_HTTP_ERROR); } if (status != null) { - EngagementModule.engageInternal(fragment.hostingActivityRef.get(), fragment.interaction, MessageCenterInteraction.EVENT_NAME_STATUS); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_STATUS); fragment.listItems.add(status); fragment.messageCenterRecyclerViewAdapter.notifyItemInserted(fragment.listItems.size() - 1); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NavigateToLinkFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NavigateToLinkFragment.java index 3c61903e9..8525f7707 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NavigateToLinkFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NavigateToLinkFragment.java @@ -7,20 +7,16 @@ package com.apptentive.android.sdk.module.engagement.interaction.fragment; import android.app.Activity; +import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; -import android.content.ActivityNotFoundException; - import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import com.apptentive.android.sdk.ApptentiveLog; - - import com.apptentive.android.sdk.ApptentiveViewExitType; -import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.NavigateToLinkInteraction; import com.apptentive.android.sdk.util.Util; @@ -66,7 +62,7 @@ public void onCreate(Bundle savedInstanceState) { success = true; } } catch (ActivityNotFoundException e) { - ApptentiveLog.w("NavigateToLink Error: ", e); + ApptentiveLog.w(e, "NavigateToLink Error: "); } finally { JSONObject data = new JSONObject(); try { @@ -74,9 +70,9 @@ public void onCreate(Bundle savedInstanceState) { data.put(NavigateToLinkInteraction.KEY_TARGET, interaction.getTarget().lowercaseName()); data.put(NavigateToLinkInteraction.EVENT_KEY_SUCCESS, success); } catch (JSONException e) { - ApptentiveLog.e("Error creating Event data object.", e); + ApptentiveLog.e(e, "Error creating Event data object."); } - EngagementModule.engageInternal(getActivity(), interaction, NavigateToLinkInteraction.EVENT_NAME_NAVIGATE, data.toString()); + engageInternal(NavigateToLinkInteraction.EVENT_NAME_NAVIGATE, data.toString()); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NoteFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NoteFragment.java index c5237fc3f..876c2ca28 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NoteFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/NoteFragment.java @@ -14,11 +14,10 @@ import android.widget.LinearLayout; import android.widget.TextView; -import com.apptentive.android.sdk.ApptentiveInternal; - import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; +import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.module.engagement.interaction.model.Interactions; @@ -108,9 +107,9 @@ public void onClick(View view) { data.put(Action.KEY_LABEL, buttonAction.getLabel()); data.put(TextModalInteraction.EVENT_KEY_ACTION_POSITION, position); } catch (JSONException e) { - ApptentiveLog.e("Error creating Event data object.", e); + ApptentiveLog.e(e, "Error creating Event data object."); } - EngagementModule.engageInternal(getActivity(), interaction, TextModalInteraction.EVENT_NAME_DISMISS, data.toString()); + engageInternal(TextModalInteraction.EVENT_NAME_DISMISS, data.toString()); transit(); } }); @@ -132,9 +131,17 @@ public void onClick(View view) { Interaction invokedInteraction = null; if (interactionIdToLaunch != null) { - Interactions interactions = ApptentiveInternal.getInstance().getInteractionManager().getInteractions(); - if (interactions != null) { - invokedInteraction = interactions.getInteraction(interactionIdToLaunch); + Conversation conversation = getConversation(); + if (conversation != null) { + String interactionsString = conversation.getInteractions(); + if (interactionsString != null) { + try { + Interactions interactions = new Interactions(interactionsString); + invokedInteraction = interactions.getInteraction(interactionIdToLaunch); + }catch (JSONException e) { + // Should never happen. + } + } } } @@ -145,10 +152,10 @@ public void onClick(View view) { data.put(TextModalInteraction.EVENT_KEY_ACTION_POSITION, position); data.put(TextModalInteraction.EVENT_KEY_INVOKED_INTERACTION_ID, invokedInteraction == null ? JSONObject.NULL : invokedInteraction.getId()); } catch (JSONException e) { - ApptentiveLog.e("Error creating Event data object.", e); + ApptentiveLog.e(e, "Error creating Event data object."); } - EngagementModule.engageInternal(getActivity(), interaction, TextModalInteraction.EVENT_NAME_INTERACTION, data.toString()); + engageInternal(TextModalInteraction.EVENT_NAME_INTERACTION, data.toString()); if (invokedInteraction != null) { EngagementModule.launchInteraction(getActivity(), invokedInteraction); } @@ -168,7 +175,7 @@ public void onClick(View view) { @Override public boolean onFragmentExit(ApptentiveViewExitType exitType) { - EngagementModule.engageInternal(getActivity(), interaction, TextModalInteraction.EVENT_NAME_CANCEL, exitTypeToDataJson(exitType)); + engageInternal(TextModalInteraction.EVENT_NAME_CANCEL, exitTypeToDataJson(exitType)); return false; } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/RatingDialogFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/RatingDialogFragment.java index 0db7eec52..03e610cd3 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/RatingDialogFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/RatingDialogFragment.java @@ -15,7 +15,6 @@ import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; -import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.RatingDialogInteraction; public class RatingDialogFragment extends ApptentiveBaseFragment { @@ -54,7 +53,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa rateButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_RATE); + engageInternal(CODE_POINT_RATE); transit(); } }); @@ -68,7 +67,7 @@ public void onClick(View view) { remindButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_REMIND); + engageInternal(CODE_POINT_REMIND); transit(); } }); @@ -82,7 +81,7 @@ public void onClick(View view) { declineButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_DECLINE); + engageInternal(CODE_POINT_DECLINE); transit(); } }); @@ -91,7 +90,7 @@ public void onClick(View view) { @Override public boolean onFragmentExit(ApptentiveViewExitType exitType) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_CANCEL, exitTypeToDataJson(exitType)); + engageInternal(CODE_POINT_CANCEL, exitTypeToDataJson(exitType)); return false; } } \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/SurveyFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/SurveyFragment.java index 67be3ed71..856de2f85 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/SurveyFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/SurveyFragment.java @@ -26,12 +26,10 @@ import android.widget.Toast; import com.apptentive.android.sdk.ApptentiveInternal; - import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; -import com.apptentive.android.sdk.model.SurveyResponse; -import com.apptentive.android.sdk.module.engagement.EngagementModule; +import com.apptentive.android.sdk.model.SurveyResponsePayload; import com.apptentive.android.sdk.module.engagement.interaction.model.SurveyInteraction; import com.apptentive.android.sdk.module.engagement.interaction.model.survey.MultichoiceQuestion; import com.apptentive.android.sdk.module.engagement.interaction.model.survey.MultiselectQuestion; @@ -120,9 +118,9 @@ public void onClick(View view) { } getActivity().finish(); - EngagementModule.engageInternal(getActivity(), interaction, EVENT_SUBMIT); + engageInternal(EVENT_SUBMIT); - ApptentiveInternal.getInstance().getApptentiveTaskManager().addPayload(new SurveyResponse(interaction, answers)); + getConversation().addPayload(new SurveyResponsePayload(interaction, answers)); ApptentiveLog.d("Survey Submitted."); callListener(true); } else { @@ -257,7 +255,7 @@ void sendMetricForQuestion(Activity activity, String questionId) { } catch (JSONException e) { // Never happens. } - EngagementModule.engageInternal(activity, interaction, EVENT_QUESTION_RESPONSE, answerData.toString()); + engageInternal(EVENT_QUESTION_RESPONSE, answerData.toString()); } private void callListener(boolean completed) { @@ -270,11 +268,11 @@ private void callListener(boolean completed) { @Override public boolean onFragmentExit(ApptentiveViewExitType exitType) { if (exitType.equals(ApptentiveViewExitType.BACK_BUTTON)) { - EngagementModule.engageInternal(getActivity(), interaction, EVENT_CANCEL); + engageInternal(EVENT_CANCEL); } else if (exitType.equals(ApptentiveViewExitType.NOTIFICATION)) { - EngagementModule.engageInternal(getActivity(), interaction, EVENT_CANCEL, exitTypeToDataJson(exitType)); + engageInternal(EVENT_CANCEL, exitTypeToDataJson(exitType)); } else { - EngagementModule.engageInternal(getActivity(), interaction, EVENT_CLOSE); + engageInternal(EVENT_CLOSE, exitTypeToDataJson(exitType)); } return false; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/UpgradeMessageFragment.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/UpgradeMessageFragment.java index 07a9ebf64..67d060fb3 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/UpgradeMessageFragment.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/fragment/UpgradeMessageFragment.java @@ -23,7 +23,6 @@ import com.apptentive.android.sdk.ApptentiveViewExitType; import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.model.Configuration; -import com.apptentive.android.sdk.module.engagement.EngagementModule; import com.apptentive.android.sdk.module.engagement.interaction.model.UpgradeMessageInteraction; public class UpgradeMessageFragment extends ApptentiveBaseFragment { @@ -66,7 +65,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, @Override public boolean onFragmentExit(ApptentiveViewExitType exitType) { - EngagementModule.engageInternal(getActivity(), interaction, CODE_POINT_DISMISS, exitTypeToDataJson(exitType)); + engageInternal(CODE_POINT_DISMISS, exitTypeToDataJson(exitType)); return false; } @@ -77,7 +76,7 @@ private Drawable getIconDrawableResourceId() { PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0); return ContextCompat.getDrawable(getContext(), pi.applicationInfo.icon); } catch (Exception e) { - ApptentiveLog.e("Error loading app icon.", e); + ApptentiveLog.e(e, "Error loading app icon."); } return null; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java index 32f87842e..d5658f929 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interaction.java @@ -143,7 +143,7 @@ public static Interaction parseInteraction(String interactionString) { break; } } catch (JSONException e) { - ApptentiveLog.w("Error parsing Interaction", e); + ApptentiveLog.w(e, "Error parsing Interaction"); // Ignore } return null; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionCriteria.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionCriteria.java index edfc8d954..e04914ff8 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionCriteria.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionCriteria.java @@ -34,9 +34,9 @@ public boolean isMet() { ApptentiveLog.i("- => %b", ret); return ret; } catch (JSONException e) { - ApptentiveLog.w("Error parsing and running InteractionCriteria predicate logic.", e); + ApptentiveLog.w(e, "Error parsing and running InteractionCriteria predicate logic."); } catch (Exception e) { - ApptentiveLog.w("Error parsing and running InteractionCriteria predicate logic.", e); + ApptentiveLog.w(e, "Error parsing and running InteractionCriteria predicate logic."); } return false; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionsPayload.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionManifest.java similarity index 84% rename from apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionsPayload.java rename to apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionManifest.java index 6ce2d0b2a..ba7fe5f68 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionsPayload.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/InteractionManifest.java @@ -12,12 +12,9 @@ import org.json.JSONException; import org.json.JSONObject; -/** - * @author Sky Kelsey - */ -public class InteractionsPayload extends JSONObject { +public class InteractionManifest extends JSONObject { - public InteractionsPayload(String json) throws JSONException { + public InteractionManifest(String json) throws JSONException { super(json); } @@ -45,7 +42,7 @@ public Interactions getInteractions() { } } } catch (JSONException e) { - ApptentiveLog.w("Unable to load Interactions from InteractionsPayload.", e); + ApptentiveLog.w(e, "Unable to load Interactions from InteractionManifest."); } return null; } @@ -57,7 +54,7 @@ public Targets getTargets() { return new Targets(targets.toString()); } } catch (JSONException e) { - ApptentiveLog.w("Unable to load Targets from InteractionsPayload.", e); + ApptentiveLog.w(e, "Unable to load Targets from InteractionManifest."); } return null; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interactions.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interactions.java index b6568a131..1ca5ae471 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interactions.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/Interactions.java @@ -36,7 +36,7 @@ public Interaction getInteraction(String id) { return Interaction.Factory.parseInteraction(getJSONObject(id).toString()); } } catch (JSONException e) { - ApptentiveLog.w("Exception parsing interactions array.", e); + ApptentiveLog.w(e, "Exception parsing interactions array."); } return null; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/common/Actions.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/common/Actions.java index 39bf8100f..84bbcfe6d 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/common/Actions.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/model/common/Actions.java @@ -32,7 +32,7 @@ public List getAsList() { } } } catch (JSONException e) { - ApptentiveLog.w("Exception parsing interactions array.", e); + ApptentiveLog.w(e, "Exception parsing interactions array."); } return ret; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/MultichoiceSurveyQuestionView.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/MultichoiceSurveyQuestionView.java index 0b7cf6c15..43236b6d4 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/MultichoiceSurveyQuestionView.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/MultichoiceSurveyQuestionView.java @@ -153,7 +153,7 @@ public Object getAnswer() { } } } catch (Exception e) { - ApptentiveLog.e("Error getting survey answer.", e); + ApptentiveLog.e(e, "Error getting survey answer."); } return null; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/SurveyQuestionChoice.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/SurveyQuestionChoice.java index 688581847..eaf22e381 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/SurveyQuestionChoice.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/interaction/view/survey/SurveyQuestionChoice.java @@ -143,7 +143,7 @@ public Object getAnswer() { } return answer; } catch (Exception e) { - ApptentiveLog.e("Error producing survey answer.", e); + ApptentiveLog.e(e, "Error producing survey answer."); } return null; } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/ClauseParser.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/ClauseParser.java index e335c085f..2ff5eef4c 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/ClauseParser.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/ClauseParser.java @@ -87,9 +87,9 @@ public static Object parseValue(Object value) { if (typeName != null) { try { if (Apptentive.Version.TYPE.equals(typeName)) { - return new Apptentive.Version(jsonObject.toString()); + return new Apptentive.Version(jsonObject); } else if (Apptentive.DateTime.TYPE.equals(typeName)) { - return new Apptentive.DateTime(jsonObject.toString()); + return new Apptentive.DateTime(jsonObject); } else { throw new RuntimeException(String.format("Error parsing complex parameter with unrecognized name: \"%s\"", typeName)); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/FieldManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/FieldManager.java index aa54f1dd1..977da3e91 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/FieldManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/engagement/logic/FieldManager.java @@ -10,12 +10,10 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.BuildConfig; -import com.apptentive.android.sdk.model.CustomData; -import com.apptentive.android.sdk.model.Device; -import com.apptentive.android.sdk.model.Person; -import com.apptentive.android.sdk.storage.DeviceManager; -import com.apptentive.android.sdk.storage.PersonManager; -import com.apptentive.android.sdk.storage.VersionHistoryStore; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.storage.CustomData; +import com.apptentive.android.sdk.storage.Device; +import com.apptentive.android.sdk.storage.Person; import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; @@ -33,6 +31,10 @@ public static Comparable getValue(String query) { } public static Object doGetValue(String query) { + Conversation conversation = ApptentiveInternal.getInstance().getConversation(); // FIXME: get rid of singleton + if (conversation == null) { + return null; + } query = query.trim(); String[] tokens = query.split("/"); QueryPart topLevelQuery = QueryPart.parse(tokens[0]); @@ -78,9 +80,9 @@ public static Object doGetValue(String query) { QueryPart subQuery = QueryPart.parse(tokens[1]); switch (subQuery) { case version_code: - return VersionHistoryStore.isUpdate(VersionHistoryStore.Selector.version_code); + return conversation.getVersionHistory().isUpdateForVersionCode(); case version_name: - return VersionHistoryStore.isUpdate(VersionHistoryStore.Selector.version_name); + return conversation.getVersionHistory().isUpdateForVersionName(); default: break; } @@ -90,40 +92,73 @@ public static Object doGetValue(String query) { QueryPart subQuery = QueryPart.parse(tokens[1]); switch (subQuery) { case total: - return VersionHistoryStore.getTimeAtInstall(VersionHistoryStore.Selector.total); + return conversation.getVersionHistory().getTimeAtInstallTotal(); case version_code: - return VersionHistoryStore.getTimeAtInstall(VersionHistoryStore.Selector.version_code); + return conversation.getVersionHistory().getTimeAtInstallForCurrentVersionCode(); case version_name: - return VersionHistoryStore.getTimeAtInstall(VersionHistoryStore.Selector.version_name); + return conversation.getVersionHistory().getTimeAtInstallForCurrentVersionName(); } return new Apptentive.DateTime(Util.currentTimeSeconds()); } - case interactions: + case interactions: { + String interactionId = tokens[1]; + QueryPart queryPart1 = QueryPart.parse(tokens[2]); + switch (queryPart1) { + case invokes: + QueryPart queryPart2 = QueryPart.parse(tokens[3]); + switch (queryPart2) { + case total: // Get total for all versions of the app. + return new BigDecimal(conversation.getEventData().getInteractionCountTotal(interactionId)); + case version_code: + Integer appVersionCode = Util.getAppVersionCode(ApptentiveInternal.getInstance().getApplicationContext()); + return new BigDecimal(conversation.getEventData().getInteractionCountForVersionCode(interactionId, appVersionCode)); + case version_name: + String appVersionName = Util.getAppVersionName(ApptentiveInternal.getInstance().getApplicationContext()); + return new BigDecimal(conversation.getEventData().getInteractionCountForVersionName(interactionId, appVersionName)); + default: + break; + } + break; + case last_invoked_at: + QueryPart queryPart3 = QueryPart.parse(tokens[3]); + switch (queryPart3) { + case total: + Double lastInvoke = conversation.getEventData().getTimeOfLastInteractionInvocation(interactionId); + if (lastInvoke != null) { + return new Apptentive.DateTime(lastInvoke); + } + default: + break; + } + default: + break; + } + break; + } case code_point: { - boolean isInteraction = topLevelQuery.equals(QueryPart.interactions); - String name = tokens[1]; + String eventLabel = tokens[1]; QueryPart queryPart1 = QueryPart.parse(tokens[2]); - switch (queryPart1) { case invokes: QueryPart queryPart2 = QueryPart.parse(tokens[3]); switch (queryPart2) { case total: // Get total for all versions of the app. - return new BigDecimal(ApptentiveInternal.getInstance().getCodePointStore().getTotalInvokes(isInteraction, name)); + return new BigDecimal(conversation.getEventData().getEventCountTotal(eventLabel)); case version_code: - String appVersionCode = String.valueOf(Util.getAppVersionCode(ApptentiveInternal.getInstance().getApplicationContext())); - return new BigDecimal(ApptentiveInternal.getInstance().getCodePointStore().getVersionCodeInvokes(isInteraction, name, appVersionCode)); + Integer appVersionCode = Util.getAppVersionCode(ApptentiveInternal.getInstance().getApplicationContext()); + return new BigDecimal(conversation.getEventData().getEventCountForVersionCode(eventLabel, appVersionCode)); case version_name: String appVersionName = Util.getAppVersionName(ApptentiveInternal.getInstance().getApplicationContext()); - return new BigDecimal(ApptentiveInternal.getInstance().getCodePointStore().getVersionNameInvokes(isInteraction, name, appVersionName)); + return new BigDecimal(conversation.getEventData().getEventCountForVersionName(eventLabel, appVersionName)); default: break; } + break; case last_invoked_at: QueryPart queryPart3 = QueryPart.parse(tokens[3]); switch (queryPart3) { case total: - Double lastInvoke = ApptentiveInternal.getInstance().getCodePointStore().getLastInvoke(isInteraction, name); + Double lastInvoke = conversation.getEventData().getTimeOfLastEventInvocation(eventLabel); if (lastInvoke != null) { return new Apptentive.DateTime(lastInvoke); } @@ -137,7 +172,7 @@ public static Object doGetValue(String query) { } case person: { QueryPart subQuery = QueryPart.parse(tokens[1]); - Person person = PersonManager.getStoredPerson(); + Person person = conversation.getPerson(); if (person == null) { return null; } @@ -146,21 +181,20 @@ public static Object doGetValue(String query) { String customDataKey = tokens[2].trim(); CustomData customData = person.getCustomData(); if (customData != null) { - return customData.opt(customDataKey); + return customData.get(customDataKey); } break; case name: - return person.getEmail(); + return person.getName(); case email: return person.getEmail(); case other: - String key = tokens[1]; - return person.opt(key); + return null; } } case device: { QueryPart subQuery = QueryPart.parse(tokens[1]); - Device device = DeviceManager.getStoredDevice(); + Device device = conversation.getDevice(); if (device == null) { return null; } @@ -169,39 +203,63 @@ public static Object doGetValue(String query) { String customDataKey = tokens[2].trim(); CustomData customData = device.getCustomData(); if (customData != null) { - return customData.opt(customDataKey); + return customData.get(customDataKey); } break; case os_version: - String osVersion = device.optString(subQuery.name(), "0"); + String osVersion = device.getOsVersion(); + if (osVersion == null) { + osVersion = "0"; + } Apptentive.Version ret = new Apptentive.Version(); ret.setVersion(osVersion); return ret; case os_api_level: - return device.optInt(subQuery.name(), 0); + return device.getOsApiLevel(); case board: + return device.getBoard(); case bootloader_version: + return device.getBootloaderVersion(); case brand: + return device.getBrand(); case build_id: + return device.getBuildId(); case build_type: + return device.getBuildType(); case carrier: + return device.getCarrier(); case cpu: + return device.getCpu(); case current_carrier: + return device.getCurrentCarrier(); case device: + return device.getDevice(); case hardware: + return null; // What is this key? case locale_country_code: + return device.getLocaleCountryCode(); case locale_language_code: + return device.getLocaleLanguageCode(); case locale_raw: + return device.getLocaleRaw(); case manufacturer: + return device.getManufacturer(); case model: + return device.getModel(); case network_type: + return device.getNetworkType(); case os_name: + return device.getOsName(); case os_build: + return device.getOsBuild(); case product: + return device.getProduct(); case radio_version: + return device.getRadioVersion(); case uuid: + return device.getUuid(); case other: - return device.opt(subQuery.name()); + return null; } } default: @@ -266,7 +324,7 @@ public static QueryPart parse(String name) { try { return QueryPart.valueOf(name); } catch (IllegalArgumentException e) { - ApptentiveLog.d(String.format("Unrecognized QueryPart: \"%s\". Defaulting to \"unknown\"", name), e); + ApptentiveLog.d(e, "Unrecognized QueryPart: \"%s\". Defaulting to \"unknown\"", name); } } return other; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessageManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessageManager.java index bb8390de8..ac9a10131 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessageManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessageManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -10,27 +10,29 @@ import android.app.Notification; import android.app.PendingIntent; import android.content.Context; -import android.content.SharedPreferences; -import android.os.AsyncTask; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.comm.ApptentiveClient; import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.PayloadData; +import com.apptentive.android.sdk.model.PayloadType; import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveToastNotification; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterListItem; import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; import com.apptentive.android.sdk.module.metric.MetricModule; +import com.apptentive.android.sdk.notifications.ApptentiveNotification; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationCenter; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationObserver; import com.apptentive.android.sdk.storage.MessageStore; -import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.Destroyable; import com.apptentive.android.sdk.util.Util; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchTask; import org.json.JSONArray; import org.json.JSONException; @@ -40,10 +42,22 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import java.util.concurrent.Future; import java.util.concurrent.atomic.AtomicBoolean; -public class MessageManager { +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_ACTIVITY_RESUMED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_ACTIVITY_STARTED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_APP_ENTERED_BACKGROUND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_APP_ENTERED_FOREGROUND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_ACTIVITY; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_PAYLOAD; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_RESPONSE_CODE; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_RESPONSE_DATA; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_SUCCESSFUL; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_PAYLOAD_DID_FINISH_SEND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_PAYLOAD_WILL_START_SEND; +import static com.apptentive.android.sdk.debug.Assert.assertNotNull; + +public class MessageManager implements Destroyable, ApptentiveNotificationObserver { // The reason of pause message sending public static int SEND_PAUSE_REASON_ACTIVITY_PAUSE = 0; @@ -52,69 +66,52 @@ public class MessageManager { private static int TOAST_TYPE_UNREAD_MESSAGE = 1; - private static final int UI_THREAD_MESSAGE_ON_UNREAD_HOST = 1; - private static final int UI_THREAD_MESSAGE_ON_UNREAD_INTERNAL = 2; - private static final int UI_THREAD_MESSAGE_ON_TOAST_NOTIFICATION = 3; + private final Conversation conversation; + + private final MessageStore messageStore; private WeakReference currentForegroundApptentiveActivity; private WeakReference afterSendMessageListener; - private final List> internalNewMessagesListeners = new ArrayList>(); - + private final List> internalNewMessagesListeners = new ArrayList<>(); /* UnreadMessagesListener is set by external hosting app, and its lifecycle is managed by the app. * Use WeakReference to prevent memory leak */ - private final List> hostUnreadMessagesListeners = new ArrayList>(); + private final List> hostUnreadMessagesListeners = new ArrayList<>(); - AtomicBoolean appInForeground = new AtomicBoolean(false); - private Handler uiHandler; - private MessagePollingWorker pollingWorker; + final AtomicBoolean appInForeground = new AtomicBoolean(false); // FIXME: get rid of that + private final MessagePollingWorker pollingWorker; + private final MessageDispatchTask toastMessageNotifierTask = new MessageDispatchTask() { + @Override + protected void execute(CompoundMessage message) { + showUnreadMessageToastNotification(message); + } + }; - public MessageManager() { + private final MessageCountDispatchTask hostMessageNotifierTask = new MessageCountDispatchTask() { + @Override + protected void execute(int messageCount) { + notifyHostUnreadMessagesListeners(messageCount); + } + }; - } + public MessageManager(Conversation conversation, MessageStore messageStore) { + if (conversation == null) { + throw new IllegalArgumentException("Conversation is null"); + } - // init() will start polling worker. - public void init() { - if (uiHandler == null) { - uiHandler = new Handler(Looper.getMainLooper()) { - @Override - public void handleMessage(android.os.Message msg) { - switch (msg.what) { - case UI_THREAD_MESSAGE_ON_UNREAD_HOST: - notifyHostUnreadMessagesListeners(msg.arg1); - break; - case UI_THREAD_MESSAGE_ON_UNREAD_INTERNAL: { - // Notify internal listeners such as Message Center - CompoundMessage msgToAdd = (CompoundMessage) msg.obj; - notifyInternalNewMessagesListeners(msgToAdd); - break; - } - case UI_THREAD_MESSAGE_ON_TOAST_NOTIFICATION: { - CompoundMessage msgToShow = (CompoundMessage) msg.obj; - showUnreadMessageToastNotification(msgToShow); - break; - } - default: - super.handleMessage(msg); - } - } - }; - } - if (pollingWorker == null) { - pollingWorker = new MessagePollingWorker(this); - /* Set SharePreference to indicate Message Center feature is desired. It will always be checked - * during Apptentive initialization. - */ - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - boolean featureEverUsed = prefs.getBoolean(Constants.PREF_KEY_MESSAGE_CENTER_FEATURE_USED, false); - if (!featureEverUsed) { - prefs.edit().putBoolean(Constants.PREF_KEY_MESSAGE_CENTER_FEATURE_USED, true).apply(); - } + if (messageStore == null) { + throw new IllegalArgumentException("Message store is null"); } + + this.conversation = conversation; + this.messageStore = messageStore; + this.pollingWorker = new MessagePollingWorker(this); + + registerNotifications(); } /* @@ -122,36 +119,23 @@ public void handleMessage(android.os.Message msg) { * when push is received on the device. */ public void startMessagePreFetchTask() { - // Defer message polling thread creation, if not created yet and host app receives a new message push - init(); - AsyncTask task = new AsyncTask() { - private Exception e = null; - + final boolean updateMC = isMessageCenterInForeground(); + DispatchQueue.backgroundQueue().dispatchAsync(new DispatchTask() { @Override - protected Void doInBackground(Object... params) { - boolean updateMC = (Boolean) params[0]; + protected void execute() { try { fetchAndStoreMessages(updateMC, false); - } catch (Exception e) { - this.e = e; - } - return null; - } - - @Override - protected void onPostExecute(Void v) { - if (e != null) { - ApptentiveLog.w("Unhandled Exception thrown from fetching new message asyncTask", e); - MetricModule.sendError(e, null, null); + } catch (final Exception e) { + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + ApptentiveLog.w(e, "Unhandled Exception thrown from fetching new message task"); + MetricModule.sendError(e, null, null); + } + }); } } - }; - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { - task.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, isMessageCenterInForeground()); - } else { - task.execute(isMessageCenterInForeground()); - } + }); } /** @@ -160,11 +144,7 @@ protected void onPostExecute(Void v) { * * @return true if messages were returned, else false. */ - public synchronized boolean fetchAndStoreMessages(boolean isMessageCenterForeground, boolean showToast) { - if (ApptentiveInternal.getInstance().getApptentiveConversationToken() == null) { - ApptentiveLog.d("Can't fetch messages because the conversation has not yet been initialized."); - return false; - } + synchronized boolean fetchAndStoreMessages(boolean isMessageCenterForeground, boolean showToast) { if (!Util.isNetworkConnectionPresent()) { ApptentiveLog.d("Can't fetch messages because a network connection is not present."); return false; @@ -173,8 +153,8 @@ public synchronized boolean fetchAndStoreMessages(boolean isMessageCenterForegro // Fetch the messages. List messagesToSave = null; try { - Future future = getMessageStore().getLastReceivedMessageId(); - messagesToSave = fetchMessages(future.get()); + String lastMessageId = messageStore.getLastReceivedMessageId(); + messagesToSave = fetchMessages(lastMessageId); } catch (Exception e) { ApptentiveLog.e("Error retrieving last received message id from worker thread"); } @@ -185,36 +165,36 @@ public synchronized boolean fetchAndStoreMessages(boolean isMessageCenterForegro // Also get the count of incoming unread messages. int incomingUnreadMessages = 0; // Mark messages from server where sender is the app user as read. - for (ApptentiveMessage apptentiveMessage : messagesToSave) { + for (final ApptentiveMessage apptentiveMessage : messagesToSave) { if (apptentiveMessage.isOutgoingMessage()) { apptentiveMessage.setRead(true); } else { if (messageOnToast == null) { - if (apptentiveMessage.getType() == ApptentiveMessage.Type.CompoundMessage) { + if (apptentiveMessage.getMessageType() == ApptentiveMessage.Type.CompoundMessage) { messageOnToast = (CompoundMessage) apptentiveMessage; } } incomingUnreadMessages++; + // for every new message received, notify Message Center - Message msg = uiHandler.obtainMessage(UI_THREAD_MESSAGE_ON_UNREAD_INTERNAL, apptentiveMessage); - msg.sendToTarget(); + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + notifyInternalNewMessagesListeners((CompoundMessage) apptentiveMessage); + } + }); } } - getMessageStore().addOrUpdateMessages(messagesToSave.toArray(new ApptentiveMessage[messagesToSave.size()])); - Message msg; + messageStore.addOrUpdateMessages(messagesToSave.toArray(new ApptentiveMessage[messagesToSave.size()])); if (incomingUnreadMessages > 0) { // Show toast notification only if the foreground activity is not already message center activity if (!isMessageCenterForeground && showToast) { - msg = uiHandler.obtainMessage(UI_THREAD_MESSAGE_ON_TOAST_NOTIFICATION, messageOnToast); - // Only show the latest new message on toast - uiHandler.removeMessages(UI_THREAD_MESSAGE_ON_TOAST_NOTIFICATION); - msg.sendToTarget(); + DispatchQueue.mainQueue().dispatchAsyncOnce(toastMessageNotifierTask.setMessage(messageOnToast)); } } + // Send message to notify host app, such as unread message badge - msg = uiHandler.obtainMessage(UI_THREAD_MESSAGE_ON_UNREAD_HOST, getUnreadMessageCount(), 0); - uiHandler.removeMessages(UI_THREAD_MESSAGE_ON_UNREAD_HOST); - msg.sendToTarget(); + DispatchQueue.mainQueue().dispatchAsyncOnce(hostMessageNotifierTask.setMessageCount(getUnreadMessageCount())); return incomingUnreadMessages > 0; } @@ -222,9 +202,9 @@ public synchronized boolean fetchAndStoreMessages(boolean isMessageCenterForegro } public List getMessageCenterListItems() { - List messagesToShow = new ArrayList(); + List messagesToShow = new ArrayList<>(); try { - List messagesAll = getMessageStore().getAllMessages().get(); + List messagesAll = messageStore.getAllMessages(); // Do not display hidden messages on Message Center for (ApptentiveMessage message : messagesAll) { if (!message.isHidden()) { @@ -239,8 +219,12 @@ public List getMessageCenterListItems() { } public void sendMessage(ApptentiveMessage apptentiveMessage) { - getMessageStore().addOrUpdateMessages(apptentiveMessage); - ApptentiveInternal.getInstance().getApptentiveTaskManager().addPayload(apptentiveMessage); + messageStore.addOrUpdateMessages(apptentiveMessage); + conversation.addPayload(apptentiveMessage); + } + + public void addMessages(ApptentiveMessage[] messages) { + messageStore.addOrUpdateMessages(messages); } /** @@ -248,37 +232,42 @@ public void sendMessage(ApptentiveMessage apptentiveMessage) { */ public void deleteAllMessages(Context context) { ApptentiveLog.d("Deleting all messages."); - getMessageStore().deleteAllMessages(); + messageStore.deleteAllMessages(); } private List fetchMessages(String afterId) { ApptentiveLog.d("Fetching messages newer than: %s", (afterId == null) ? "0" : afterId); - ApptentiveHttpResponse response = ApptentiveClient.getMessages(null, afterId, null); + if (!Util.isNetworkConnectionPresent()) { + ApptentiveLog.v("No internet present. Cancelling request."); + return null; + } + // TODO: Use the new ApptentiveHttpClient for this. + ApptentiveHttpResponse response = ApptentiveClient.getMessages(conversation, afterId, null, null); - List ret = new ArrayList(); + List ret = new ArrayList<>(); if (!response.isSuccessful()) { return ret; } try { ret = parseMessagesString(response.getContent()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing messages JSON.", e); + ApptentiveLog.e(e, "Error parsing messages JSON."); } catch (Exception e) { - ApptentiveLog.e("Unexpected error parsing messages JSON.", e); + ApptentiveLog.e(e, "Unexpected error parsing messages JSON."); } return ret; } public void updateMessage(ApptentiveMessage apptentiveMessage) { - getMessageStore().updateMessage(apptentiveMessage); + messageStore.updateMessage(apptentiveMessage); } - public List parseMessagesString(String messageString) throws JSONException { - List ret = new ArrayList(); + private List parseMessagesString(String messageString) throws JSONException { + List ret = new ArrayList<>(); JSONObject root = new JSONObject(messageString); - if (!root.isNull("items")) { - JSONArray items = root.getJSONArray("items"); + if (!root.isNull("messages")) { + JSONArray items = root.getJSONArray("messages"); for (int i = 0; i < items.length(); i++) { String json = items.getJSONObject(i).toString(); ApptentiveMessage apptentiveMessage = MessageFactory.fromJson(json); @@ -304,68 +293,142 @@ public void pauseSending(int reason_code) { } } - public void onSentMessage(ApptentiveMessage apptentiveMessage, ApptentiveHttpResponse response) { + public void onSentMessage(String nonce, int responseCode, JSONObject responseJson) { + + final ApptentiveMessage apptentiveMessage = messageStore.findMessage(nonce); + assertNotNull(apptentiveMessage, "Can't find a message with nonce: %s", nonce); + if (apptentiveMessage == null) { + return; // should not happen but we want to stay safe + } - if (response.isRejectedPermanently() || response.isBadPayload()) { + final boolean isRejectedPermanently = responseCode >= 400 && responseCode < 500; + final boolean isSuccessful = responseCode >= 200 && responseCode < 300; + final boolean isRejectedTemporarily = !(isSuccessful || isRejectedPermanently); + + if (isRejectedPermanently || responseCode == -1) { if (apptentiveMessage instanceof CompoundMessage) { apptentiveMessage.setCreatedAt(Double.MIN_VALUE); - getMessageStore().updateMessage(apptentiveMessage); + messageStore.updateMessage(apptentiveMessage); if (afterSendMessageListener != null && afterSendMessageListener.get() != null) { - afterSendMessageListener.get().onMessageSent(response, apptentiveMessage); + afterSendMessageListener.get().onMessageSent(responseCode, apptentiveMessage); } } return; } - if (response.isRejectedTemporarily()) { + if (isRejectedTemporarily) { pauseSending(SEND_PAUSE_REASON_SERVER); return; } - if (response.isSuccessful()) { + if (isSuccessful) { + assertNotNull(responseJson, "Missing required responseJson."); // Don't store hidden messages once sent. Delete them. if (apptentiveMessage.isHidden()) { ((CompoundMessage) apptentiveMessage).deleteAssociatedFiles(); - getMessageStore().deleteMessage(apptentiveMessage.getNonce()); + messageStore.deleteMessage(apptentiveMessage.getNonce()); return; } try { - JSONObject responseJson = new JSONObject(response.getContent()); - apptentiveMessage.setState(ApptentiveMessage.State.sent); - apptentiveMessage.setId(responseJson.getString(ApptentiveMessage.KEY_ID)); apptentiveMessage.setCreatedAt(responseJson.getDouble(ApptentiveMessage.KEY_CREATED_AT)); } catch (JSONException e) { - ApptentiveLog.e("Error parsing sent apptentiveMessage response.", e); + ApptentiveLog.e(e, "Error parsing sent apptentiveMessage response."); } - getMessageStore().updateMessage(apptentiveMessage); + messageStore.updateMessage(apptentiveMessage); if (afterSendMessageListener != null && afterSendMessageListener.get() != null) { - afterSendMessageListener.get().onMessageSent(response, apptentiveMessage); + afterSendMessageListener.get().onMessageSent(responseCode, apptentiveMessage); } } } - private MessageStore getMessageStore() { - return ApptentiveInternal.getInstance().getApptentiveTaskManager(); - } - public int getUnreadMessageCount() { int msgCount = 0; try { - msgCount = getMessageStore().getUnreadMessageCount().get(); + msgCount = messageStore.getUnreadMessageCount(); } catch (Exception e) { ApptentiveLog.e("Error getting unread messages count in worker thread"); } return msgCount; } + //region Notifications + + private void registerNotifications() { + ApptentiveNotificationCenter.defaultCenter() + .addObserver(NOTIFICATION_ACTIVITY_STARTED, this) + .addObserver(NOTIFICATION_ACTIVITY_RESUMED, this) + .addObserver(NOTIFICATION_APP_ENTERED_FOREGROUND, this) + .addObserver(NOTIFICATION_APP_ENTERED_BACKGROUND, this) + .addObserver(NOTIFICATION_PAYLOAD_WILL_START_SEND, this) + .addObserver(NOTIFICATION_PAYLOAD_DID_FINISH_SEND, this); + } + + //endregion + + //region Notification Observer + + @Override + public void onReceiveNotification(ApptentiveNotification notification) { + if (notification.hasName(NOTIFICATION_ACTIVITY_STARTED) || + notification.hasName(NOTIFICATION_ACTIVITY_RESUMED)) { + final Activity activity = notification.getRequiredUserInfo(NOTIFICATION_KEY_ACTIVITY, Activity.class); + setCurrentForegroundActivity(activity); + } else if (notification.hasName(NOTIFICATION_APP_ENTERED_FOREGROUND)) { + appWentToForeground(); + } else if (notification.hasName(NOTIFICATION_APP_ENTERED_BACKGROUND)) { + setCurrentForegroundActivity(null); + appWentToBackground(); + } else if (notification.hasName(NOTIFICATION_PAYLOAD_WILL_START_SEND)) { + final PayloadData payload = notification.getRequiredUserInfo(NOTIFICATION_KEY_PAYLOAD, PayloadData.class); + if (payload.getType().equals(PayloadType.message)) { + resumeSending(); + } + } else if (notification.hasName(NOTIFICATION_PAYLOAD_DID_FINISH_SEND)) { + final boolean successful = notification.getRequiredUserInfo(NOTIFICATION_KEY_SUCCESSFUL, Boolean.class); + final PayloadData payload = notification.getRequiredUserInfo(NOTIFICATION_KEY_PAYLOAD, PayloadData.class); + final Integer responseCode = notification.getRequiredUserInfo(NOTIFICATION_KEY_RESPONSE_CODE, Integer.class); + final JSONObject responseData = successful ? notification.getRequiredUserInfo(NOTIFICATION_KEY_RESPONSE_DATA, JSONObject.class) : null; + if (responseCode == -1) { + pauseSending(SEND_PAUSE_REASON_NETWORK); + } + + if (payload.getType().equals(PayloadType.message)) { + onSentMessage(payload.getNonce(), responseCode, responseData); + } + } + } + + //endregion + + //region Destroyable + + @Override + public void destroy() { + ApptentiveNotificationCenter.defaultCenter().removeObserver(this); + pollingWorker.destroy(); + } + + //endregion + + //region Polling + + public void startPollingMessages() { + pollingWorker.startPolling(); + } + + public void stopPollingMessages() { + pollingWorker.stopPolling(); + } + + //endregion // Listeners public interface AfterSendMessageListener { - void onMessageSent(ApptentiveHttpResponse response, ApptentiveMessage apptentiveMessage); + void onMessageSent(int responseCode, ApptentiveMessage apptentiveMessage); void onPauseSending(int reason); @@ -378,7 +441,7 @@ public interface OnNewIncomingMessagesListener { public void setAfterSendMessageListener(AfterSendMessageListener listener) { if (listener != null) { - afterSendMessageListener = new WeakReference(listener); + afterSendMessageListener = new WeakReference<>(listener); } else { afterSendMessageListener = null; } @@ -387,7 +450,6 @@ public void setAfterSendMessageListener(AfterSendMessageListener listener) { public void addInternalOnMessagesUpdatedListener(OnNewIncomingMessagesListener newlistener) { if (newlistener != null) { - init(); for (Iterator> iterator = internalNewMessagesListeners.iterator(); iterator.hasNext(); ) { WeakReference listenerRef = iterator.next(); OnNewIncomingMessagesListener listener = listenerRef.get(); @@ -397,7 +459,7 @@ public void addInternalOnMessagesUpdatedListener(OnNewIncomingMessagesListener n iterator.remove(); } } - internalNewMessagesListeners.add(new WeakReference(newlistener)); + internalNewMessagesListeners.add(new WeakReference<>(newlistener)); } } @@ -405,7 +467,7 @@ public void clearInternalOnMessagesUpdatedListeners() { internalNewMessagesListeners.clear(); } - public void notifyInternalNewMessagesListeners(final CompoundMessage apptentiveMsg) { + private void notifyInternalNewMessagesListeners(final CompoundMessage apptentiveMsg) { for (WeakReference listenerRef : internalNewMessagesListeners) { OnNewIncomingMessagesListener listener = listenerRef.get(); if (listener != null) { @@ -418,14 +480,13 @@ public void notifyInternalNewMessagesListeners(final CompoundMessage apptentiveM public void setHostUnreadMessagesListener(UnreadMessagesListener listener) { clearHostUnreadMessagesListeners(); if (listener != null) { - hostUnreadMessagesListeners.add(new WeakReference(listener)); + hostUnreadMessagesListeners.add(new WeakReference<>(listener)); } } public void addHostUnreadMessagesListener(UnreadMessagesListener newListener) { if (newListener != null) { - // Defer message polling thread creation, if not created yet, and host app adds an unread message listener - init(); + // Defer message polling thread creation, if not created yet, and host app adds an unread message listenerinit(); for (Iterator> iterator = hostUnreadMessagesListeners.iterator(); iterator.hasNext(); ) { WeakReference listenerRef = iterator.next(); UnreadMessagesListener listener = listenerRef.get(); @@ -435,11 +496,11 @@ public void addHostUnreadMessagesListener(UnreadMessagesListener newListener) { iterator.remove(); } } - hostUnreadMessagesListeners.add(new WeakReference(newListener)); + hostUnreadMessagesListeners.add(new WeakReference<>(newListener)); } } - public void clearHostUnreadMessagesListeners() { + private void clearHostUnreadMessagesListeners() { hostUnreadMessagesListeners.clear(); } @@ -453,9 +514,9 @@ public void notifyHostUnreadMessagesListeners(int unreadMessages) { } // Set when Activity.onStart() and onStop() are called - public void setCurrentForegroundActivity(Activity activity) { + private void setCurrentForegroundActivity(Activity activity) { if (activity != null) { - currentForegroundApptentiveActivity = new WeakReference(activity); + currentForegroundApptentiveActivity = new WeakReference<>(activity); } else { ApptentiveToastNotificationManager manager = ApptentiveToastNotificationManager.getInstance(null, false); if (manager != null) { @@ -469,7 +530,7 @@ public void setMessageCenterInForeground(boolean bInForeground) { pollingWorker.setMessageCenterInForeground(bInForeground); } - public boolean isMessageCenterInForeground() { + private boolean isMessageCenterInForeground() { return pollingWorker.messageCenterInForeground.get(); } @@ -486,30 +547,75 @@ private void showUnreadMessageToastNotification(final CompoundMessage apptentive .setSmallIcon(R.drawable.avatar).setContentText(apptentiveMsg.getBody()) .setContentIntent(pendingIntent) .setFullScreenIntent(pendingIntent, false); - foreground.runOnUiThread(new Runnable() { - public void run() { - ApptentiveToastNotification notification = builder.buildApptentiveToastNotification(); - notification.setAvatarUrl(apptentiveMsg.getSenderProfilePhoto()); - manager.notify(TOAST_TYPE_UNREAD_MESSAGE, notification); - } - } + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + ApptentiveToastNotification notification = builder.buildApptentiveToastNotification(); + notification.setAvatarUrl(apptentiveMsg.getSenderProfilePhoto()); + manager.notify(TOAST_TYPE_UNREAD_MESSAGE, notification); + } + } ); } } } } - public void appWentToForeground() { + private void appWentToForeground() { appInForeground.set(true); - if (pollingWorker != null) { - pollingWorker.appWentToForeground(); - } + pollingWorker.appWentToForeground(); } - public void appWentToBackground() { + private void appWentToBackground() { appInForeground.set(false); - if (pollingWorker != null) { - pollingWorker.appWentToBackground(); + pollingWorker.appWentToBackground(); + } + + Conversation getConversation() { + return conversation; + } + + public MessageStore getMessageStore() { + return messageStore; + } + + //region Message Dispatch Task + + private abstract static class MessageDispatchTask extends DispatchTask { + private CompoundMessage message; + + protected abstract void execute(CompoundMessage message); + + @Override + protected void execute() { + try { + execute(message); + } finally { + message = null; + } + } + + MessageDispatchTask setMessage(CompoundMessage message) { + this.message = message; + return this; + } + } + + private abstract static class MessageCountDispatchTask extends DispatchTask { + private int messageCount; + + protected abstract void execute(int messageCount); + + @Override + protected void execute() { + execute(messageCount); + } + + MessageCountDispatchTask setMessageCount(int messageCount) { + this.messageCount = messageCount; + return this; } } + + //endregion } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessagePollingWorker.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessagePollingWorker.java index f5d4f4052..910de485f 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessagePollingWorker.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/MessagePollingWorker.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -7,142 +7,179 @@ package com.apptentive.android.sdk.module.messagecenter; -import com.apptentive.android.sdk.Apptentive; +import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.model.Configuration; -import com.apptentive.android.sdk.module.metric.MetricModule; +import com.apptentive.android.sdk.util.Destroyable; import java.util.concurrent.atomic.AtomicBoolean; -/** - * @author Sky Kelsey - */ -public class MessagePollingWorker { +import static com.apptentive.android.sdk.ApptentiveLogTag.*; - private MessagePollingThread sPollingThread; +class MessagePollingWorker implements Destroyable { - // The following booleans will be accessed by both ui thread and worker thread - public AtomicBoolean messageCenterInForeground = new AtomicBoolean(false); - private AtomicBoolean threadRunning = new AtomicBoolean(false); + private final MessageManager messageManager; + private final long backgroundPollingInterval; + private final long foregroundPollingInterval; + private final Configuration conf; - private MessageManager manager; + /** + * Worker thread for background message fetching + */ + private MessagePollingThread pollingThread; - public MessagePollingWorker(MessageManager manager) { - this.manager = manager; - } + final AtomicBoolean messageCenterInForeground = new AtomicBoolean(); // TODO: remove this flag - // A synchronized getter/setter to the static instance of thread object - public synchronized MessagePollingThread getAndSetMessagePollingThread(boolean expect, - boolean create) { - if (expect && create) { - sPollingThread = createPollingThread(); - } else if (!expect) { - sPollingThread = null; + MessagePollingWorker(MessageManager messageManager) { + if (messageManager == null) { + throw new IllegalArgumentException("Message manager is null"); } - return sPollingThread; - } - private MessagePollingThread createPollingThread() { - MessagePollingThread newThread = new MessagePollingThread(); - Thread.UncaughtExceptionHandler handler = new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread thread, Throwable throwable) { - MetricModule.sendError(throwable, null, null); - } - }; - newThread.setUncaughtExceptionHandler(handler); - newThread.setName("Apptentive-MessagePollingWorker"); - newThread.start(); - return newThread; + this.messageManager = messageManager; + + conf = Configuration.load(); + backgroundPollingInterval = conf.getMessageCenterBgPoll() * 1000; + foregroundPollingInterval = conf.getMessageCenterFgPoll() * 1000; } + @Override + public void destroy() { + stopPolling(); + } private class MessagePollingThread extends Thread { - - private long backgroundPollingInterval = -1; - private long foregroundPollingInterval = -1; - private Configuration conf; - - public MessagePollingThread() { - conf = Configuration.load(); - backgroundPollingInterval = conf.getMessageCenterBgPoll() * 1000; - foregroundPollingInterval = conf.getMessageCenterFgPoll() * 1000; + /** + * Flag indicating if message polling is active (message polling stops when this flag is set to + * false + */ + private final AtomicBoolean isPolling = new AtomicBoolean(true); + + /** + * Flag indicating if polling thread is busy with message fetching. + */ + private final AtomicBoolean isFetching = new AtomicBoolean(false); + + MessagePollingThread() { + super("Message Polling Thread (" + getLocalConversationIdentifier() + ")"); } + @Override public void run() { try { - ApptentiveLog.v("Started %s", toString()); + ApptentiveLog.v(MESSAGES, "%s started", getName()); + + while (isPolling.get()) { - while (manager.appInForeground.get()) { - MessagePollingThread thread = getAndSetMessagePollingThread(true, false); - if (thread != null && thread != MessagePollingThread.this) { - return; + // sync poll message and mark thread as 'fetching' + isFetching.set(true); + pollMessagesSync(); + isFetching.set(false); + + // if we're done polling - no need to sleep + if (!isPolling.get()) { + break; } + + // sleep until next iteration long pollingInterval = messageCenterInForeground.get() ? foregroundPollingInterval : backgroundPollingInterval; - if (Apptentive.canShowMessageCenter()) { - ApptentiveLog.v("Checking server for new messages every %d seconds", pollingInterval / 1000); - manager.fetchAndStoreMessages(messageCenterInForeground.get(), conf.isMessageCenterNotificationPopupEnabled()); - } + ApptentiveLog.v(MESSAGES, "Scheduled polling messages in %d sec", pollingInterval / 1000); goToSleep(pollingInterval); } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while polling messages"); } finally { - threadRunning.set(false); - sPollingThread = null; - ApptentiveLog.v("Stopping MessagePollingThread."); + ApptentiveLog.v(MESSAGES, "%s stopped", getName()); + } + } + + private void pollMessagesSync() { + try { + if (ApptentiveInternal.getInstance().canShowMessageCenterInternal(getConversation())) { + ApptentiveLog.v(MESSAGES, "Checking server for new messages..."); + messageManager.fetchAndStoreMessages(messageCenterInForeground.get(), conf.isMessageCenterNotificationPopupEnabled()); + } else { + ApptentiveLog.w(MESSAGES, "Unable to fetch messages: message center can't be show at this time"); + } + } catch (Exception e) { + ApptentiveLog.e(MESSAGES, e, "Exception while polling messages"); } } - } - private void goToSleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - // This is normal and happens whenever we wake the thread with an interrupt. + private void goToSleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + ApptentiveLog.vv(MESSAGES, Thread.currentThread().getName() + " interrupted from sleep"); + } + } + + /** + * Stops current polling + */ + void stopPolling() { + isPolling.set(false); + interrupt(); } - } - private void wakeUp() { - ApptentiveLog.v("Waking MessagePollingThread."); - MessagePollingThread thread = getAndSetMessagePollingThread(true, false); - if (thread != null && thread.isAlive()) { - thread.interrupt(); + /** + * Wake thread from a sleep + */ + void wakeUp() { + if (!isFetching.get()) { + interrupt(); + } else { + ApptentiveLog.vv("Can't wake up polling thread while it's synchronously fetching new messages"); + } } } // Called from main UI thread to create a new worker thread - public void appWentToForeground() { + void appWentToForeground() { startPolling(); } - - public void appWentToBackground() { - // When app goes to background, polling thread will be waken up, and finish and terminate - wakeUp(); + void appWentToBackground() { + stopPolling(); } /** * If coming from the background, wake the thread so that it immediately starts runs and runs more often. If coming - * from the foreground, let the polling interval timeout naturally, at which point the polling interval will become - * the background polling interval. + * from the foreground, let the isPolling interval timeout naturally, at which point the isPolling interval will become + * the background isPolling interval. * - * @param bInForeground true if the worker should be in foreground polling mode, else false. + * @param foreground true if the worker should be in foreground isPolling mode, else false. */ - public void setMessageCenterInForeground(boolean bInForeground) { - if (!messageCenterInForeground.getAndSet(bInForeground) && bInForeground) { - /* bInForeground is "true" && messageCenterInForeground was false - */ + public void setMessageCenterInForeground(boolean foreground) { + messageCenterInForeground.set(foreground); + if (foreground) { startPolling(); } } - private void startPolling() { - if (threadRunning.compareAndSet(false, true)) { - // If polling thread isn't running (either terminated or hasn't been created it), create a new one and run - getAndSetMessagePollingThread(true, true); + synchronized void startPolling() { + ApptentiveLog.v(MESSAGES, "Start polling messages (%s)", getLocalConversationIdentifier()); + if (pollingThread == null) { + pollingThread = new MessagePollingThread(); + pollingThread.start(); } else { - // If polling thread has been created but in sleep, wake it up, then continue the while loop and proceed - // with fetching with shorter interval - wakeUp(); + pollingThread.wakeUp(); + } + } + + synchronized void stopPolling() { + ApptentiveLog.v(MESSAGES, "Stop polling messages (%s)", getLocalConversationIdentifier()); + if (pollingThread != null) { + pollingThread.stopPolling(); + pollingThread = null; } } + + private Conversation getConversation() { + return messageManager.getConversation(); + } + + private String getLocalConversationIdentifier() { + return getConversation().getLocalIdentifier(); + } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/CompoundMessage.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/CompoundMessage.java deleted file mode 100644 index 672587b8d..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/CompoundMessage.java +++ /dev/null @@ -1,282 +0,0 @@ -/* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.module.messagecenter.model; - -import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.model.StoredFile; -import com.apptentive.android.sdk.util.image.ImageItem; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Future; - -public class CompoundMessage extends ApptentiveMessage implements MessageCenterUtil.CompoundMessageCommonInterface { - - private static final String KEY_BODY = "body"; - - public static final String KEY_TEXT_ONLY = "text_only"; - - private static final String KEY_TITLE = "title"; - - private static final String KEY_ATTACHMENTS = "attachments"; - - private boolean isLast; - - private boolean hasNoAttachments = true; - - private boolean isOutgoing = true; - - /* For incoming message, this array stores attachment Urls - * StoredFile::apptentiveUri is set by the "url" of the remote attachment file - * StoredFile:localFilePath is set by the "thumbnail_url" of the remote attachment (maybe empty) - */ - private ArrayList remoteAttachmentStoredFiles; - - // Default constructor will only be called when the message is created from local, a.k.a outgoing - public CompoundMessage() { - super(); - isOutgoing = true; - } - - /* Constructing compound message when JSON is received from incoming, or repopulated from database - * - * @param json The JSON string of the message - * @param bOutgoing true if the message is originated from local - */ - public CompoundMessage(String json, boolean bOutgoing) throws JSONException { - super(json); - parseAttachmentsArray(json); - hasNoAttachments = getTextOnly(); - isOutgoing = bOutgoing; - } - - - @Override - protected void initType() { - setType(Type.CompoundMessage); - } - - @Override - public String marshallForSending() { - return toString(); - } - - // Get text message body, maybe empty - @Override - public String getBody() { - try { - if (!isNull(KEY_BODY)) { - return getString(KEY_BODY); - } - } catch (JSONException e) { - // Ignore - } - return null; - } - - // Set text message body, maybe empty - @Override - public void setBody(String body) { - try { - put(KEY_BODY, body); - } catch (JSONException e) { - ApptentiveLog.e("Unable to set message body."); - } - } - - public String getTitle() { - try { - return getString(KEY_TITLE); - } catch (JSONException e) { - // Ignore - } - return null; - } - - public void setTitle(String title) { - try { - put(KEY_TITLE, title); - } catch (JSONException e) { - ApptentiveLog.e("Unable to set title."); - } - } - - public boolean getTextOnly() { - try { - return getBoolean(KEY_TEXT_ONLY); - } catch (JSONException e) { - // Ignore - } - return true; - } - - public void setTextOnly(boolean bVal) { - try { - put(KEY_TEXT_ONLY, bVal); - } catch (JSONException e) { - ApptentiveLog.e("Unable to set file filePath."); - } - } - - - public boolean setAssociatedImages(List attachedImages) { - - if (attachedImages == null || attachedImages.size() == 0) { - hasNoAttachments = true; - return false; - } else { - hasNoAttachments = false; - } - setTextOnly(hasNoAttachments); - ArrayList attachmentStoredFiles = new ArrayList(); - for (ImageItem image : attachedImages) { - StoredFile storedFile = new StoredFile(); - storedFile.setId(getNonce()); - storedFile.setApptentiveUri(""); - storedFile.setSourceUriOrPath(image.originalPath); - // ToDo: look for local cache - storedFile.setLocalFilePath(image.localCachePath); - storedFile.setMimeType("image/jpeg"); - storedFile.setCreationTime(image.time); - attachmentStoredFiles.add(storedFile); - } - boolean bRet = false; - try { - Future future = ApptentiveInternal.getInstance().getApptentiveTaskManager().addCompoundMessageFiles(attachmentStoredFiles); - bRet = future.get(); - } catch (Exception e) { - ApptentiveLog.e("Unable to set associated images in worker thread"); - } finally { - return bRet; - } - } - - public boolean setAssociatedFiles(List attachedFiles) { - - if (attachedFiles == null || attachedFiles.size() == 0) { - hasNoAttachments = true; - return false; - } else { - hasNoAttachments = false; - } - setTextOnly(hasNoAttachments); - - boolean bRet = false; - try { - Future future = ApptentiveInternal.getInstance().getApptentiveTaskManager().addCompoundMessageFiles(attachedFiles); - bRet = future.get(); - } catch (Exception e) { - ApptentiveLog.e("Unable to set associated files in worker thread"); - } finally { - return bRet; - } - } - - - public List getAssociatedFiles() { - if (hasNoAttachments) { - return null; - } - List associatedFiles = null; - try { - Future> future = ApptentiveInternal.getInstance().getApptentiveTaskManager().getAssociatedFiles(getNonce()); - associatedFiles = future.get(); - } catch (InterruptedException | ExecutionException e) { - ApptentiveLog.e("Unable to get associated files in worker thread"); - } finally { - return associatedFiles; - } - } - - public void deleteAssociatedFiles() { - try { - Future> future = ApptentiveInternal.getInstance().getApptentiveTaskManager().getAssociatedFiles(getNonce()); - List associatedFiles = future.get(); - // Delete local cached files - if (associatedFiles == null || associatedFiles.size() == 0) { - return; - } - - for (StoredFile file : associatedFiles) { - File localFile = new File(file.getLocalFilePath()); - localFile.delete(); - } - // Delete records from db - ApptentiveInternal.getInstance().getApptentiveTaskManager().deleteAssociatedFiles(getNonce()); - } catch (Exception e) { - ApptentiveLog.e("Unable to delete associated files in worker thread"); - } - } - - - @Override - public boolean isLastSent() { - return (isOutgoingMessage()) ? isLast : false; - } - - @Override - public void setLastSent(boolean bVal) { - isLast = bVal; - } - - @Override - public boolean isOutgoingMessage() { - return isOutgoing; - } - - public List getRemoteAttachments() { - return remoteAttachmentStoredFiles; - } - - /* Parse attachment array in json. Only incoming compound message would have "attachments" key set - * @param messageString JSON string of the message - * @return true if attachment array is found in JSON - */ - private boolean parseAttachmentsArray(String messageString) throws JSONException { - JSONObject root = new JSONObject(messageString); - if (!root.isNull(KEY_ATTACHMENTS)) { - JSONArray items = root.getJSONArray(KEY_ATTACHMENTS); - remoteAttachmentStoredFiles = new ArrayList(); - for (int i = 0; i < items.length(); i++) { - String json = items.getJSONObject(i).toString(); - JSONObject attachment = new JSONObject(json); - String mimeType = attachment.optString("content_type"); - StoredFile storedFile = new StoredFile(); - storedFile.setId(getNonce()); - storedFile.setApptentiveUri(attachment.optString("url")); - storedFile.setSourceUriOrPath(attachment.optString("thumbnail_url")); - storedFile.setLocalFilePath(attachment.optString("")); - storedFile.setMimeType(mimeType); - storedFile.setCreationTime(0); - remoteAttachmentStoredFiles.add(storedFile); - } - if (remoteAttachmentStoredFiles.size() > 0) { - setTextOnly(false); - return true; - } - } - return false; - } - - @Override - public int getListItemType() { - if (isAutomatedMessage()) { - return MESSAGE_AUTO; - } else if (isOutgoing) { - return MESSAGE_OUTGOING; - } else { - return MESSAGE_INCOMING; - } - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/MessageFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/MessageFactory.java index 0e160d268..8d998f47d 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/MessageFactory.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/model/MessageFactory.java @@ -1,20 +1,21 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ package com.apptentive.android.sdk.module.messagecenter.model; -import android.text.TextUtils; - -import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.CompoundMessage; +import com.apptentive.android.sdk.util.StringUtils; import org.json.JSONException; import org.json.JSONObject; public class MessageFactory { + public static ApptentiveMessage fromJson(String json) { try { // If KEY_TYPE is set to CompoundMessage or not set, treat them as CompoundMessage @@ -22,33 +23,20 @@ public static ApptentiveMessage fromJson(String json) { JSONObject root = new JSONObject(json); if (!root.isNull(ApptentiveMessage.KEY_TYPE)) { String typeStr = root.getString(ApptentiveMessage.KEY_TYPE); - if (!TextUtils.isEmpty(typeStr)) { + if (!StringUtils.isNullOrEmpty(typeStr)) { type = ApptentiveMessage.Type.valueOf(typeStr); } } switch (type) { case CompoundMessage: - String senderId = null; - try { - if (!root.isNull(ApptentiveMessage.KEY_SENDER)) { - JSONObject sender = root.getJSONObject(ApptentiveMessage.KEY_SENDER); - if (!sender.isNull((ApptentiveMessage.KEY_SENDER_ID))) { - senderId = sender.getString(ApptentiveMessage.KEY_SENDER_ID); - } - } - } catch (JSONException e) { - // Ignore, senderId would be null - } - String personId = ApptentiveInternal.getInstance().getPersonId(); - // If senderId is null or same as the locally stored id, construct message as outgoing - return new CompoundMessage(json, (senderId == null || (personId != null && senderId.equals(personId)))); + return new CompoundMessage(json); case unknown: break; default: break; } } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Message: %s", e, json); + ApptentiveLog.v(e, "Error parsing json as Message: %s", json); } catch (IllegalArgumentException e) { // Exception treated as unknown type } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/ApptentiveAvatarView.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/ApptentiveAvatarView.java index 3ce7abe20..1238399fb 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/ApptentiveAvatarView.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/ApptentiveAvatarView.java @@ -150,7 +150,7 @@ private Bitmap getBitmapFromDrawable(Drawable d) { d.draw(canvas); return b; } catch (OutOfMemoryError e) { - ApptentiveLog.w("Error creating bitmap.", e); + ApptentiveLog.w(e, "Error creating bitmap."); return null; } } @@ -222,7 +222,7 @@ public void run() { URL url = new URL(urlString); bitmap = BitmapFactory.decodeStream(url.openStream()); } catch (IOException e) { - ApptentiveLog.d("Error opening avatar from URL: \"%s\"", e, urlString); + ApptentiveLog.d(e, "Error opening avatar from URL: \"%s\"", urlString); } if (bitmap != null) { final Bitmap finalBitmap = bitmap; @@ -237,7 +237,7 @@ public void run() { Thread.UncaughtExceptionHandler handler = new Thread.UncaughtExceptionHandler() { @Override public void uncaughtException(Thread thread, Throwable throwable) { - ApptentiveLog.w("UncaughtException in AvatarView.", throwable); + ApptentiveLog.w(throwable, "UncaughtException in AvatarView."); MetricModule.sendError(throwable, null, null); } }; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageCenterRecyclerViewAdapter.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageCenterRecyclerViewAdapter.java index e16cc4702..7bd0f7b4f 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageCenterRecyclerViewAdapter.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageCenterRecyclerViewAdapter.java @@ -15,15 +15,14 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.R; -import com.apptentive.android.sdk.module.engagement.EngagementModule; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.module.engagement.interaction.fragment.MessageCenterFragment; import com.apptentive.android.sdk.module.engagement.interaction.model.Interaction; import com.apptentive.android.sdk.module.engagement.interaction.model.MessageCenterInteraction; import com.apptentive.android.sdk.module.messagecenter.MessageManager; import com.apptentive.android.sdk.module.messagecenter.OnListviewItemActionListener; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; import com.apptentive.android.sdk.module.messagecenter.model.Composer; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; import com.apptentive.android.sdk.module.messagecenter.model.ContextMessage; import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterGreeting; import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterListItem; @@ -248,11 +247,11 @@ protected Void doInBackground(ApptentiveMessage... messages) { JSONObject data = new JSONObject(); try { data.put("message_id", messages[0].getId()); - data.put("message_type", messages[0].getType().name()); + data.put("message_type", messages[0].getMessageType().name()); } catch (JSONException e) { // } - EngagementModule.engageInternal(fragment.getContext(), interaction, MessageCenterInteraction.EVENT_NAME_READ, data.toString()); + fragment.engageInternal(MessageCenterInteraction.EVENT_NAME_READ, data.toString()); MessageManager mgr = ApptentiveInternal.getInstance().getMessageManager(); if (mgr != null) { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageView.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageView.java index 764df2e68..84114ed14 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageView.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/MessageView.java @@ -9,7 +9,7 @@ import android.content.Context; import android.widget.LinearLayout; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.ApptentiveMessage; /** * @author Sky Kelsey diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/AutomatedMessageHolder.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/AutomatedMessageHolder.java index df6bdc28b..0bc999954 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/AutomatedMessageHolder.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/AutomatedMessageHolder.java @@ -11,7 +11,7 @@ import android.widget.TextView; import com.apptentive.android.sdk.R; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.model.CompoundMessage; public class AutomatedMessageHolder extends RecyclerView.ViewHolder { public TextView body; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/IncomingCompoundMessageHolder.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/IncomingCompoundMessageHolder.java index 3302f5b1a..a7c443ead 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/IncomingCompoundMessageHolder.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/IncomingCompoundMessageHolder.java @@ -16,7 +16,7 @@ import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.model.StoredFile; import com.apptentive.android.sdk.module.engagement.interaction.fragment.MessageCenterFragment; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.module.messagecenter.view.ApptentiveAvatarView; import com.apptentive.android.sdk.module.messagecenter.view.MessageCenterRecyclerViewAdapter; import com.apptentive.android.sdk.util.Util; @@ -39,8 +39,6 @@ public class IncomingCompoundMessageHolder extends MessageHolder { private TextView nameView; private ApptentiveImageGridView imageBandView; - private static final boolean loadAvatar = false; - public IncomingCompoundMessageHolder(View itemView) { super(itemView); root = itemView.findViewById(R.id.message_root); @@ -54,9 +52,7 @@ public IncomingCompoundMessageHolder(View itemView) { public void bindView(MessageCenterFragment fragment, final RecyclerView parent, final MessageCenterRecyclerViewAdapter adapter, final CompoundMessage message) { super.bindView(fragment, parent, message); imageBandView.setupUi(); - if (loadAvatar) { - ImageUtil.startDownloadAvatarTask(avatar, message.getSenderProfilePhoto()); - } + ImageUtil.startDownloadAvatarTask(avatar, message.getSenderProfilePhoto()); int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY); root.measure(widthMeasureSpec, 0); diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/MessageHolder.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/MessageHolder.java index fef57d5a4..d912a8ac1 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/MessageHolder.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/MessageHolder.java @@ -13,7 +13,7 @@ import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.module.engagement.interaction.fragment.MessageCenterFragment; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.model.CompoundMessage; public abstract class MessageHolder extends RecyclerView.ViewHolder { public TextView datestamp; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/OutgoingCompoundMessageHolder.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/OutgoingCompoundMessageHolder.java index fa8583d7f..10bfa23b8 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/OutgoingCompoundMessageHolder.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/messagecenter/view/holder/OutgoingCompoundMessageHolder.java @@ -16,7 +16,7 @@ import com.apptentive.android.sdk.R; import com.apptentive.android.sdk.model.StoredFile; import com.apptentive.android.sdk.module.engagement.interaction.fragment.MessageCenterFragment; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.module.messagecenter.view.MessageCenterRecyclerViewAdapter; import com.apptentive.android.sdk.util.Util; import com.apptentive.android.sdk.util.image.ApptentiveImageGridView; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/module/metric/MetricModule.java b/apptentive/src/main/java/com/apptentive/android/sdk/module/metric/MetricModule.java index 0fa3d2f1a..29ddf1bbc 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/module/metric/MetricModule.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/module/metric/MetricModule.java @@ -6,11 +6,13 @@ package com.apptentive.android.sdk.module.metric; +import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.model.Configuration; -import com.apptentive.android.sdk.model.Event; -import com.apptentive.android.sdk.model.EventManager; +import com.apptentive.android.sdk.model.EventPayload; import com.apptentive.android.sdk.util.Util; + import org.json.JSONObject; import java.util.Map; @@ -22,21 +24,21 @@ public class MetricModule { private static final String KEY_EXCEPTION = "exception"; - public static void sendMetric(Event.EventLabel type) { + public static void sendMetric(EventPayload.EventLabel type) { sendMetric(type, null); } - public static void sendMetric(Event.EventLabel type, String trigger) { + public static void sendMetric(EventPayload.EventLabel type, String trigger) { sendMetric(type, trigger, null); } - public static void sendMetric(Event.EventLabel type, String trigger, Map data) { + public static void sendMetric(EventPayload.EventLabel type, String trigger, Map data) { Configuration config = Configuration.load(); if (config.isMetricsEnabled()) { ApptentiveLog.v("Sending Metric: %s, trigger: %s, data: %s", type.getLabelName(), trigger, data != null ? data.toString() : "null"); - Event event = new Event(type.getLabelName(), trigger); + EventPayload event = new EventPayload(type.getLabelName(), trigger); event.putData(data); - EventManager.sendEvent(event); + sendEvent(event); } } @@ -48,7 +50,7 @@ public static void sendMetric(Event.EventLabel type, String trigger, Map requestProperties; + + /** + * Request method (GET, POST, PUT) + */ + private HttpRequestMethod method = HttpRequestMethod.GET; + + /** + * Connection timeout in milliseconds + */ + private int connectTimeout = Constants.DEFAULT_CONNECT_TIMEOUT_MILLIS; + + /** + * Read timeout in milliseconds + */ + private int readTimeout = Constants.DEFAULT_READ_TIMEOUT_MILLIS; + + /** + * The status code from an HTTP response + */ + private int responseCode; + + /** + * HTTP response content string + */ + private String responseData; + + /** + * Map of connection response headers + */ + private Map responseHeaders; + + /** + * Cancelled flag (not thread safe) + */ + private boolean cancelled; + + /** + * Error message for the failed request + */ + private String errorMessage; + + /** + * Retry policy for this request + */ + private HttpRequestRetryPolicy retryPolicy = DEFAULT_RETRY_POLICY; + + /** + * How many times request was retried already + */ + private int retryAttempt; + + /** + * Flag indicating if the request is currently scheduled for a retry + */ + boolean retrying; + + @SuppressWarnings("rawtypes") + private List listeners; + + /** + * Optional dispatch queue for listener callbacks + */ + private DispatchQueue callbackQueue; + + /** Optional injector for debugging purposes */ + private Injector injector; + + public HttpRequest(String urlString) { + if (urlString == null || urlString.length() == 0) { + throw new IllegalArgumentException("Invalid URL string '" + urlString + "'"); + } + + this.listeners = new ArrayList<>(1); + this.id = nextRequestId++; + this.urlString = urlString; + } + + //////////////////////////////////////////////////////////////// + // Lifecycle + + public void start() { + assertNotNull(requestManager); + if (requestManager != null) { + requestManager.startRequest(this); + } + } + + @SuppressWarnings("unchecked") + private void finishRequest() { + try { + if (isSuccessful()) { + for (Listener listener : listeners) { + try { + listener.onFinish(this); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception in request onFinish() listener"); + } + } + } else if (isCancelled()) { + for (Listener listener : listeners) { + try { + listener.onCancel(this); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception in request onCancel() listener"); + } + } + } else { + for (Listener listener : listeners) { + try { + listener.onFail(this, errorMessage); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception in request onFail() listener"); + } + } + } + } finally { + requestManager.unregisterRequest(HttpRequest.this); + } + } + + /** + * Override this method to create request data on a background thread + */ + protected byte[] createRequestData() throws IOException { + return null; + } + + /** + * Override this method in a subclass to create data from response bytes + */ + protected void handleResponse(String response) throws IOException { + } + + //////////////////////////////////////////////////////////////// + // Request async task + + /** + * Send request synchronously on a background network queue + */ + void dispatchSync(DispatchQueue networkQueue) { + long requestStartTime = System.currentTimeMillis(); + + try { + sendRequestSync(); + } catch (NetworkUnavailableException e) { + responseCode = -1; // indicates failure + errorMessage = e.getMessage(); + ApptentiveLog.w(e.getMessage()); + ApptentiveLog.w("Cancelled? %b", isCancelled()); + } catch (Exception e) { + responseCode = -1; // indicates failure + errorMessage = e.getMessage(); + ApptentiveLog.e("Cancelled? %b", isCancelled()); + if (!isCancelled()) { + ApptentiveLog.e(e, "Unable to perform request"); + } + } + + ApptentiveLog.d(NETWORK, "Request finished in %d ms", System.currentTimeMillis() - requestStartTime); + + // attempt a retry if request failed + if (isFailed() && retryRequest(networkQueue, responseCode)) { // we schedule request retry on the same queue as it was originally dispatched + return; + } + + // use custom callback queue (if any) + if (callbackQueue != null) { + callbackQueue.dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + finishRequest(); + } + }); + } else { + finishRequest(); // we don't care where the callback is dispatched until it's on a background queue + } + } + + private void sendRequestSync() throws Exception { + try { + if (injector != null) { + injector.onBeforeSend(this); + } + + URL url = new URL(urlString); + ApptentiveLog.d(NETWORK, "Performing request: %s %s", method, url); + if (ApptentiveLog.canLog(VERY_VERBOSE)) { + ApptentiveLog.vv(NETWORK, "%s", toString()); + } + retrying = false; + + connection = openConnection(url); + connection.setRequestMethod(method.toString()); + connection.setConnectTimeout(connectTimeout); + connection.setReadTimeout(readTimeout); + + if (!isNetworkConnectionPresent()) { + ApptentiveLog.d("No network connection present. Request will fail."); + throw new NetworkUnavailableException("The network is not currently active."); + } + + if (isCancelled()) { + return; + } + + if (requestProperties != null && requestProperties.size() > 0) { + setupRequestProperties(connection, requestProperties); + } + + if (!HttpRequestMethod.GET.equals(method)) { + connection.setDoInput(true); + connection.setDoOutput(true); + connection.setUseCaches(false); + + byte[] requestData = createRequestData(); + if (requestData != null && requestData.length > 0) { + OutputStream outputStream = null; + try { + outputStream = connection.getOutputStream(); + outputStream.write(requestData); + } finally { + Util.ensureClosed(outputStream); + } + } + } + + // send request + responseCode = connection.getResponseCode(); + ApptentiveLog.d(NETWORK, "Response: %d %s", responseCode, connection.getResponseMessage()); + + if (isCancelled()) { + return; + } + + // get HTTP headers + responseHeaders = getResponseHeaders(connection); + + // TODO: figure out a better way of handling response codes + boolean gzipped = isGzipContentEncoding(responseHeaders); + if (responseCode >= HttpURLConnection.HTTP_OK && responseCode < HttpURLConnection.HTTP_MULT_CHOICE) { + responseData = readResponse(connection.getInputStream(), gzipped); + ApptentiveLog.v(NETWORK, "Response data: %s", responseData); + } else { + errorMessage = StringUtils.format("Unexpected response code: %d (%s)", responseCode, connection.getResponseMessage()); + responseData = readResponse(connection.getErrorStream(), gzipped); + ApptentiveLog.w(NETWORK, "Error response data: %s", responseData); + } + + if (isCancelled()) { + return; + } + + if (injector != null) { + injector.onAfterSend(this); + } + + // optionally handle response data (should be overridden in a sub class) + handleResponse(responseData); + } finally { + closeConnection(); + } + } + + protected boolean isNetworkConnectionPresent() { + return Util.isNetworkConnectionPresent(); + } + + //region Retry + + private final DispatchTask retryDispatchTask = new DispatchTask() { + @Override + protected void execute() { + assertTrue(retrying); + assertNotNull(requestManager); + requestManager.dispatchRequest(HttpRequest.this); + } + }; + + private boolean retryRequest(DispatchQueue networkQueue, int responseCode) { + assertFalse(retryDispatchTask.isScheduled()); + + ++retryAttempt; + + if (!retryPolicy.shouldRetryRequest(responseCode, retryAttempt)) { + ApptentiveLog.v(NETWORK, "Retry policy declined request retry"); + return false; + } + + retrying = true; + networkQueue.dispatchAsyncOnce(retryDispatchTask, retryPolicy.getRetryTimeoutMillis(retryAttempt)); + + return true; + } + + //endregion + + //region Connection + + private void setupRequestProperties(HttpURLConnection connection, Map properties) { + Set> entries = properties.entrySet(); + for (Entry e : entries) { + String name = e.getKey(); + Object value = e.getValue(); + + if (name != null && value != null) { + connection.setRequestProperty(name, value.toString()); + } + } + } + + /* This method can be overridden in a subclass for customizing or mocking the connection */ + protected HttpURLConnection openConnection(URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + private void closeConnection() { + if (connection != null) { + connection.disconnect(); + connection = null; + } + } + + private static Map getResponseHeaders(HttpURLConnection connection) { + Map headers = new HashMap<>(); + Map> map = connection.getHeaderFields(); + for (Entry> entry : map.entrySet()) { + headers.put(entry.getKey(), entry.getValue().toString()); + } + return headers; + } + + private static boolean isGzipContentEncoding(Map responseHeaders) { + if (responseHeaders != null) { + String contentEncoding = responseHeaders.get("Content-Encoding"); + return contentEncoding != null && contentEncoding.equalsIgnoreCase("[gzip]"); + } + return false; + } + + private static String readResponse(InputStream is, boolean gzipped) throws IOException { + if (is == null) { + return null; + } + + try { + if (gzipped) { + is = new GZIPInputStream(is); + } + return Util.readStringFromInputStream(is, "UTF-8"); + } finally { + Util.ensureClosed(is); + } + } + + //endregion + + //region Cancellation + + /** + * Returns true if request is cancelled + */ + synchronized boolean isCancelled() { + return cancelled; + } + + /** + * Marks request as cancelled + */ + public synchronized void cancel() { + cancelled = true; + } + + //endregion + + //region HTTP request properties + + /** + * Sets HTTP request property + */ + public void setRequestProperty(String key, Object value) { + if (value != null) { + if (requestProperties == null) { + requestProperties = new HashMap<>(); + } + requestProperties.put(key, value); + } + } + + //endregion + + //region String representation + + public String toString() { + try { + byte[] requestData = createRequestData(); + String requestString; + String contentType = requestProperties.get("Content-Type").toString(); + if (contentType.contains("application/octet-stream") || contentType.contains("multipart/encrypted")) { + requestString = "Base64 encoded binary request: " + Base64.encodeToString(requestData, Base64.NO_WRAP); + } else { + requestString = new String(requestData); + } + return String.format( + "\n" + + "Request:\n" + + "\t%s %s\n" + + "\t%s\n" + + "\t%s\n" + + "Response:\n" + + "\t%d\n" + + "\t%s\n" + + "\t%s", + /* Request */ + method.name(), urlString, + requestProperties, + requestString, + /* Response */ + responseCode, + responseData, + responseHeaders); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while getting request string representation"); + } + return null; + } + + //endregion + + //region Getters/Setters + + public void setRequestManager(HttpRequestManager requestManager) { + this.requestManager = requestManager; + } + + public void setMethod(HttpRequestMethod method) { + if (method == null) { + throw new IllegalArgumentException("Method is null"); + } + + this.method = method; + } + + public void setConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public void setReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + } + + public boolean isSuccessful() { + return responseCode >= 200 && responseCode < 300; + } + + public boolean isFailed() { + return !isSuccessful() && !isCancelled(); + } + + public int getId() { + return id; + } + + public void setName(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public String getTag() { + return tag; + } + + public void setTag(String tag) { + this.tag = tag; + } + + public void addListener(Listener listener) { + if (listener == null) { + throw new IllegalArgumentException("Listener is null"); + } + + boolean contains = listeners.contains(listener); + assertFalse(contains, "Already contains listener: %s", listener); + if (!contains) { + listeners.add(listener); + } + } + + public void setCallbackQueue(DispatchQueue callbackQueue) { + this.callbackQueue = callbackQueue; + } + + public String getResponseData() { + return responseData; + } + + public int getResponseCode() { + return responseCode; + } + + public boolean isAuthenticationFailure() { + return responseCode == 401; + } + + public Apptentive.AuthenticationFailedReason getAuthenticationFailedReason() { + if (responseData != null) { + try { + JSONObject errorObject = new JSONObject(responseData); + String error = errorObject.optString("error", null); + String errorType = errorObject.optString("error_type", null); + return Apptentive.AuthenticationFailedReason.parse(errorType, error); + } catch (Exception e) { + ApptentiveLog.w(e, "Error parsing authentication failure object."); + } + } + return Apptentive.AuthenticationFailedReason.UNKNOWN; + } + + public HttpRequest setRetryPolicy(HttpRequestRetryPolicy retryPolicy) { + if (retryPolicy == null) { + throw new IllegalArgumentException("Retry policy is null"); + } + this.retryPolicy = retryPolicy; + return this; + } + + /* For unit testing */ + protected void setResponseCode(int code) { + responseCode = code; + } + + public void setInjector(Injector injector) { + this.injector = injector; + } + + //endregion + + //region Listener + + public interface Listener { + void onFinish(T request); + + void onCancel(T request); + + void onFail(T request, String reason); + } + + public static class Adapter implements Listener { + + @Override + public void onFinish(T request) { + + } + + @Override + public void onCancel(T request) { + + } + + @Override + public void onFail(T request, String reason) { + + } + } + + //endregion + + //region Debug + + public static class Injector { + public void onBeforeSend(HttpRequest request) throws Exception { + } + + public void onAfterSend(HttpRequest request) throws Exception { + } + } + + //endregion + + public static class NetworkUnavailableException extends IOException { + public NetworkUnavailableException(String message) { + super(message); + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestManager.java new file mode 100644 index 000000000..c6a523304 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestManager.java @@ -0,0 +1,172 @@ +package com.apptentive.android.sdk.network; + +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchQueueType; +import com.apptentive.android.sdk.util.threading.DispatchTask; + +import java.util.ArrayList; +import java.util.List; + +import static com.apptentive.android.sdk.debug.Assert.*; + +/** + * Class for asynchronous HTTP requests handling. + */ +public class HttpRequestManager { + /** + * List of active requests (started but not yet finished) + */ + private List activeRequests; + + /** + * Dispatch queue for blocking network operations + */ + private final DispatchQueue networkQueue; + + private Listener listener; + + /** + * Creates a request manager with a default concurrent "network" queue. + */ + public HttpRequestManager() { + this(DispatchQueue.createBackgroundQueue("Apptentive Network Queue", DispatchQueueType.Concurrent)); + } + + /** + * Creates a request manager with custom network dispatch queue + * + * @param networkQueue - dispatch queue for blocking network operations + * @throws IllegalArgumentException if queue is null + */ + public HttpRequestManager(DispatchQueue networkQueue) { + if (networkQueue == null) { + throw new IllegalArgumentException("Network queue is null"); + } + this.networkQueue = networkQueue; + this.activeRequests = new ArrayList<>(); + } + + //region Requests + + /** + * Starts network request on the network queue (method returns immediately) + */ + synchronized HttpRequest startRequest(HttpRequest request) { + if (request == null) { + throw new IllegalArgumentException("Request is null"); + } + + registerRequest(request); + dispatchRequest(request); + notifyRequestStarted(request); + + return request; + } + + /** + * Handles request synchronously + */ + void dispatchRequest(final HttpRequest request) { + networkQueue.dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + request.dispatchSync(networkQueue); + } + }); + } + + /** + * Cancel all active requests + */ + public synchronized void cancelAll() { + if (activeRequests.size() > 0) { + List temp = new ArrayList<>(activeRequests); + for (HttpRequest request : temp) { + request.cancel(); + } + } + notifyCancelledAllRequests(); + } + + /** + * Register active request + */ + synchronized void registerRequest(HttpRequest request) { + assertTrue(this == request.requestManager); + activeRequests.add(request); + } + + /** + * Unregisters active request + */ + synchronized void unregisterRequest(HttpRequest request) { + assertTrue(this == request.requestManager); + boolean removed = activeRequests.remove(request); + assertTrue(removed, "Attempted to unregister missing request: %s", request); + + if (removed) { + notifyRequestFinished(request); + } + } + + /** + * Returns a request with a specified tag or null is not found + */ + public synchronized HttpRequest findRequest(String tag) { + for (HttpRequest request : activeRequests) { + if (StringUtils.equal(request.getTag(), tag)) { + return request; + } + } + return null; + } + + //endregion + + //region Listener callbacks + + private void notifyRequestStarted(final HttpRequest request) { + if (listener != null) { + listener.onRequestStart(HttpRequestManager.this, request); + } + } + + private void notifyRequestFinished(final HttpRequest request) { + if (listener != null) { + listener.onRequestFinish(HttpRequestManager.this, request); + } + } + + private void notifyCancelledAllRequests() { + if (listener != null) { + listener.onRequestsCancel(HttpRequestManager.this); + } + } + + //endregion + + //region Getters/Setters + + public Listener getListener() { + return listener; + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + //endregion + + //region Listener + + public interface Listener { + void onRequestStart(HttpRequestManager manager, HttpRequest request); + + void onRequestFinish(HttpRequestManager manager, HttpRequest request); + + void onRequestsCancel(HttpRequestManager manager); + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestMethod.java b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestMethod.java new file mode 100644 index 000000000..22594a53d --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestMethod.java @@ -0,0 +1,8 @@ +package com.apptentive.android.sdk.network; + +public enum HttpRequestMethod { + GET, + POST, + PUT, + DELETE +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestRetryPolicy.java b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestRetryPolicy.java new file mode 100644 index 000000000..c447ab884 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestRetryPolicy.java @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.network; + +public interface HttpRequestRetryPolicy { + boolean shouldRetryRequest(int responseCode, int retryAttempt); + long getRetryTimeoutMillis(int retryAttempt); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestRetryPolicyDefault.java b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestRetryPolicyDefault.java new file mode 100644 index 000000000..05fdad295 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/network/HttpRequestRetryPolicyDefault.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.network; + +public class HttpRequestRetryPolicyDefault implements HttpRequestRetryPolicy { + public static final int RETRY_COUNT_INFINITE = -1; + + public static final long DEFAULT_RETRY_TIMEOUT_MILLIS = 5 * 1000; + public static final int DEFAULT_RETRY_COUNT = 5; + + /** + * How many times should request retry before giving up + */ + private int maxRetryCount = DEFAULT_RETRY_COUNT; + + /** + * How long should we wait before retrying again + */ + private long retryTimeoutMillis = DEFAULT_RETRY_TIMEOUT_MILLIS; + + /** + * Returns true is request should be retried. + * + * @param responseCode - HTTP response code for the request + */ + @Override + public boolean shouldRetryRequest(int responseCode, int retryAttempt) { + if (responseCode >= 400 && responseCode < 500) { + return false; // don't retry if request was rejected permanently + } + + if (maxRetryCount == RETRY_COUNT_INFINITE) { + return true; // keep retrying indefinitely + } + + return retryAttempt <= maxRetryCount; // retry if we still can + } + + /** + * Returns the delay in millis for the next retry + * + * @param retryAttempt - number of retries attempted already + */ + @Override + public long getRetryTimeoutMillis(int retryAttempt) { + return retryTimeoutMillis; + } + + public void setMaxRetryCount(int maxRetryCount) { + this.maxRetryCount = maxRetryCount; + } + + public void setRetryTimeoutMillis(long retryTimeoutMillis) { + this.retryTimeoutMillis = retryTimeoutMillis; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/network/RawHttpRequest.java b/apptentive/src/main/java/com/apptentive/android/sdk/network/RawHttpRequest.java new file mode 100644 index 000000000..3a65bfacc --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/network/RawHttpRequest.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.network; + +import java.io.IOException; + +public class RawHttpRequest extends HttpRequest { + + private final byte[] data; + + public RawHttpRequest(String urlString, byte[] data) { + super(urlString); + + if (data == null) { + throw new IllegalArgumentException("data is null"); + } + this.data = data; + } + + @Override + protected byte[] createRequestData() throws IOException { + return data; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotification.java b/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotification.java index f1441251f..8ed969bae 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotification.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotification.java @@ -1,5 +1,13 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + package com.apptentive.android.sdk.notifications; +import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.util.ObjectUtils; import com.apptentive.android.sdk.util.StringUtils; import java.util.Map; @@ -27,12 +35,27 @@ public String getName() { return name; } + public boolean hasName(String name) { + return StringUtils.equal(this.name, name); + } + + public T getRequiredUserInfo(String key, Class valueClass) { + final T userInfo = getUserInfo(key, valueClass); + // FIXME: Why was this assert here? Not all requests will have response data. + //Assert.assertNotNull(userInfo, "Missing required user info '%s' for '%s' notification", key, name); + return userInfo; + } + + public T getUserInfo(String key, Class valueClass) { + return userInfo != null ? ObjectUtils.as(userInfo.get(key), valueClass) : null; + } + public Map getUserInfo() { return userInfo; } @Override public String toString() { - return String.format("[%s] name=%s userInfo=%s", name, StringUtils.toString(userInfo)); + return String.format("name=%s userInfo={%s}", name, StringUtils.toString(userInfo)); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenter.java b/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenter.java index a288ea9a6..3f49b2cf7 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenter.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenter.java @@ -1,12 +1,20 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + package com.apptentive.android.sdk.notifications; -import com.apptentive.android.sdk.util.threading.DispatchQueue; -import com.apptentive.android.sdk.util.threading.DispatchTask; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.util.ObjectUtils; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import static com.apptentive.android.sdk.ApptentiveLogTag.*; + /** * An {@link ApptentiveNotificationCenter} object (or simply, notification center) provides a * mechanism for broadcasting information within a program. An {@link ApptentiveNotificationCenter} @@ -24,29 +32,8 @@ public class ApptentiveNotificationCenter { */ private final Map observerListLookup; - /** - * Dispatch queue for posting notifications. - */ - private final DispatchQueue notificationQueue; - - /** - * Dispatch queue for the concurrent access to the internal data structures - * (adding/removing observers, etc). - */ - private final DispatchQueue operationQueue; - - ApptentiveNotificationCenter(DispatchQueue notificationQueue, DispatchQueue operationQueue) { - if (notificationQueue == null) { - throw new IllegalArgumentException("Notification queue is not defined"); - } - - if (operationQueue == null) { - throw new IllegalArgumentException("Operation queue is not defined"); - } - + ApptentiveNotificationCenter() { this.observerListLookup = new HashMap<>(); - this.notificationQueue = notificationQueue; - this.operationQueue = operationQueue; } //region Observers @@ -54,8 +41,9 @@ public class ApptentiveNotificationCenter { /** * Adds an entry to the receiver’s dispatch table with an observer using strong reference. */ - public void addObserver(final String notification, final ApptentiveNotificationObserver observer) { + public synchronized ApptentiveNotificationCenter addObserver(String notification, ApptentiveNotificationObserver observer) { addObserver(notification, observer, false); + return this; } /** @@ -63,43 +51,28 @@ public void addObserver(final String notification, final ApptentiveNotificationO * * @param useWeakReference - weak reference is used if true */ - public void addObserver(final String notification, final ApptentiveNotificationObserver observer, final boolean useWeakReference) { - operationQueue.dispatchAsync(new DispatchTask() { - @Override - protected void execute() { - final ApptentiveNotificationObserverList list = resolveObserverList(notification); - list.addObserver(observer, useWeakReference); - } - }); + public synchronized void addObserver(String notification, ApptentiveNotificationObserver observer, boolean useWeakReference) { + final ApptentiveNotificationObserverList list = resolveObserverList(notification); + list.addObserver(observer, useWeakReference); } /** * Removes matching entries from the receiver’s dispatch table. */ - public void removeObserver(final String notification, final ApptentiveNotificationObserver observer) { - operationQueue.dispatchAsync(new DispatchTask() { - @Override - protected void execute() { - final ApptentiveNotificationObserverList list = findObserverList(notification); - if (list != null) { - list.removeObserver(observer); - } - } - }); + public synchronized void removeObserver(final String notification, final ApptentiveNotificationObserver observer) { + final ApptentiveNotificationObserverList list = findObserverList(notification); + if (list != null) { + list.removeObserver(observer); + } } /** * Removes all the entries specifying a given observer from the receiver’s dispatch table. */ - public void removeObserver(final ApptentiveNotificationObserver observer) { - operationQueue.dispatchAsync(new DispatchTask() { - @Override - protected void execute() { - for (ApptentiveNotificationObserverList observers : observerListLookup.values()) { - observers.removeObserver(observer); - } - } - }); + public synchronized void removeObserver(final ApptentiveNotificationObserver observer) { + for (ApptentiveNotificationObserverList observers : observerListLookup.values()) { + observers.removeObserver(observer); + } } //endregion @@ -107,42 +80,26 @@ protected void execute() { //region Notifications /** - * Posts a given notification to the receiver. + * Creates a notification with a given name and posts it to the receiver. */ - public void postNotification(String name) { + public synchronized void postNotification(String name) { postNotification(name, EMPTY_USER_INFO); } /** - * Creates a notification with a given name and information and posts it to the receiver. + * Creates a notification with a given name and user info and posts it to the receiver. */ - public void postNotification(String name, Map userInfo) { - postNotification(new ApptentiveNotification(name, userInfo)); + public synchronized void postNotification(final String name, Object... args) { + postNotification(name, ObjectUtils.toMap(args)); } /** - * Posts a given notification to the receiver. + * Creates a notification with a given name and user info and posts it to the receiver. */ - public void postNotification(final ApptentiveNotification notification) { - operationQueue.dispatchAsync(new DispatchTask() { - @Override - protected void execute() { - if (notificationQueue == operationQueue) { // is it the same queue? - postNotificationSync(notification); - } else { - notificationQueue.dispatchAsync(new DispatchTask() { - @Override - protected void execute() { - postNotificationSync(notification); - } - }); - } - } - }); - } + public synchronized void postNotification(final String name, final Map userInfo) { + final ApptentiveNotification notification = new ApptentiveNotification(name, userInfo); + ApptentiveLog.v(NOTIFICATIONS, "Post notification: %s", notification); - // this method is not thread-safe - private void postNotificationSync(ApptentiveNotification notification) { final ApptentiveNotificationObserverList list = findObserverList(notification.getName()); if (list != null) { list.notifyObservers(notification); @@ -158,14 +115,14 @@ private void postNotificationSync(ApptentiveNotification notification) { * * @return null is not found */ - private ApptentiveNotificationObserverList findObserverList(String name) { + private synchronized ApptentiveNotificationObserverList findObserverList(String name) { return observerListLookup.get(name); } /** * Find an observer list for the specified name or creates a new one if not found. */ - private ApptentiveNotificationObserverList resolveObserverList(String name) { + private synchronized ApptentiveNotificationObserverList resolveObserverList(String name) { ApptentiveNotificationObserverList list = observerListLookup.get(name); if (list == null) { list = new ApptentiveNotificationObserverList(); @@ -189,7 +146,7 @@ public static ApptentiveNotificationCenter defaultCenter() { * Thread-safe initialization trick */ private static class Holder { - static final ApptentiveNotificationCenter INSTANCE = new ApptentiveNotificationCenter(DispatchQueue.mainQueue(), DispatchQueue.mainQueue()); + static final ApptentiveNotificationCenter INSTANCE = new ApptentiveNotificationCenter(); } //endregion diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserver.java b/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserver.java index ec41f3438..7cda3d744 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserver.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserver.java @@ -1,3 +1,9 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + package com.apptentive.android.sdk.notifications; /** diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/serialization/ObjectSerialization.java b/apptentive/src/main/java/com/apptentive/android/sdk/serialization/ObjectSerialization.java new file mode 100644 index 000000000..4da135884 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/serialization/ObjectSerialization.java @@ -0,0 +1,52 @@ +package com.apptentive.android.sdk.serialization; + +import com.apptentive.android.sdk.util.Util; + +import java.io.DataInput; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Constructor; + +/** + * Helper class for a binary file-based object serialization. + */ +public class ObjectSerialization { + /** + * Writes an object ot a file + */ + public static void serialize(File file, SerializableObject object) throws IOException { + FileOutputStream stream = null; + try { + stream = new FileOutputStream(file); + DataOutputStream out = new DataOutputStream(stream); + object.writeExternal(out); + } finally { + Util.ensureClosed(stream); + } + } + + /** + * Reads an object from a file + */ + public static T deserialize(File file, Class cls) throws IOException { + FileInputStream stream = null; + try { + stream = new FileInputStream(file); + DataInputStream in = new DataInputStream(stream); + + try { + Constructor constructor = cls.getDeclaredConstructor(DataInput.class); + constructor.setAccessible(true); + return constructor.newInstance(in); + } catch (Exception e) { + throw new IOException("Unable to instantiate class: " + cls, e); + } + } finally { + Util.ensureClosed(stream); + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/serialization/SerializableObject.java b/apptentive/src/main/java/com/apptentive/android/sdk/serialization/SerializableObject.java new file mode 100644 index 000000000..33d255eaa --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/serialization/SerializableObject.java @@ -0,0 +1,32 @@ +package com.apptentive.android.sdk.serialization; + +import java.io.DataOutput; +import java.io.IOException; + +/** + * Only the identity of the class of an SerializableObject instance + * is written in the serialization stream and it is the responsibility + * of the class to save and restore the contents of its instances. + * The writeExternal and a single arg constructor of the SerializableObject + * interface are implemented by a class to give the class complete control + * over the format and contents of the stream for an object and its + * supertypes. These methods must explicitly coordinate with the supertype + * to save its state. + */ +public interface SerializableObject { + + /** + * The object should have a public single arg constructor accepting + * DataInput to restore its contents by calling the methods + * of DataInput for primitive types. The constructor must read the + * values in the same sequence and with the same types as were written + * by writeExternal. + */ + /* SerializableObject(DataInput in) throws IOException; */ + + /** + * The object implements the writeExternal method to save its contents + * by calling the methods of DataOutput for its primitive values. + */ + void writeExternal(DataOutput out) throws IOException; +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppRelease.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppRelease.java new file mode 100644 index 000000000..1f0ccbee5 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppRelease.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import java.io.Serializable; + +public class AppRelease implements Serializable { + + private String appStore; + private boolean debug; + private String identifier; + private boolean inheritStyle; + private boolean overrideStyle; + private String targetSdkVersion; + private String type; + private int versionCode; + private String versionName; + + //region Getters & Setters + + public String getAppStore() { + return appStore; + } + + public void setAppStore(String appStore) { + this.appStore = appStore; + } + + public boolean isDebug() { + return debug; + } + + public void setDebug(boolean debug) { + this.debug = debug; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public boolean isInheritStyle() { + return inheritStyle; + } + + public void setInheritStyle(boolean inheritStyle) { + this.inheritStyle = inheritStyle; + } + + public boolean isOverrideStyle() { + return overrideStyle; + } + + public void setOverrideStyle(boolean overrideStyle) { + this.overrideStyle = overrideStyle; + } + + public String getTargetSdkVersion() { + return targetSdkVersion; + } + + public void setTargetSdkVersion(String targetSdkVersion) { + this.targetSdkVersion = targetSdkVersion; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppReleaseManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppReleaseManager.java index 272678752..7964a4e07 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppReleaseManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/AppReleaseManager.java @@ -6,16 +6,104 @@ package com.apptentive.android.sdk.storage; -import android.content.SharedPreferences; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.model.AppRelease; -import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.model.*; +import com.apptentive.android.sdk.util.Util; public class AppReleaseManager { - public static void storeAppRelease(AppRelease appRelease) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_APP_RELEASE, appRelease.toString()).apply(); + public static AppRelease generateCurrentAppRelease(Context context, ApptentiveInternal apptentiveInternal) { + + AppRelease appRelease = new AppRelease(); + + String appPackageName = context.getPackageName(); + PackageManager packageManager = context.getPackageManager(); + + int currentVersionCode = 0; + String currentVersionName = "0"; + int targetSdkVersion = 0; + boolean isAppDebuggable = false; + try { + PackageInfo packageInfo = packageManager.getPackageInfo(appPackageName, PackageManager.GET_META_DATA | PackageManager.GET_RECEIVERS); + ApplicationInfo ai = packageInfo.applicationInfo; + currentVersionCode = packageInfo.versionCode; + currentVersionName = packageInfo.versionName; + targetSdkVersion = packageInfo.applicationInfo.targetSdkVersion; + Bundle metaData = ai.metaData; + if (metaData != null) { + isAppDebuggable = (ai.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + } catch (PackageManager.NameNotFoundException e) { + ApptentiveLog.e("Failed to read app's PackageInfo."); + } + + int themeOverrideResId = context.getResources().getIdentifier("ApptentiveThemeOverride", "style", appPackageName); + + appRelease.setAppStore(Util.getInstallerPackageName(context)); + appRelease.setDebug(isAppDebuggable); + appRelease.setIdentifier(appPackageName); + appRelease.setInheritStyle(apptentiveInternal.isAppUsingAppCompatTheme()); + appRelease.setOverrideStyle(themeOverrideResId != 0); + appRelease.setTargetSdkVersion(String.valueOf(targetSdkVersion)); + appRelease.setType("android"); + appRelease.setVersionCode(currentVersionCode); + appRelease.setVersionName(currentVersionName); + + return appRelease; + } + + public static AppReleasePayload getPayload(AppRelease appRelease) { + AppReleasePayload ret = new AppReleasePayload(); + if (appRelease == null) { + return ret; + } + + ret.setAppStore(appRelease.getAppStore()); + ret.setDebug(appRelease.isDebug()); + ret.setIdentifier(appRelease.getIdentifier()); + ret.setInheritStyle(appRelease.isInheritStyle()); + ret.setOverrideStyle(appRelease.isOverrideStyle()); + ret.setTargetSdkVersion(appRelease.getTargetSdkVersion()); + ret.setType(appRelease.getType()); + ret.setVersionCode(appRelease.getVersionCode()); + ret.setVersionName(appRelease.getVersionName()); + return ret; + } + + // TODO: this method might not belong here + public static com.apptentive.android.sdk.model.SdkAndAppReleasePayload getPayload(Sdk sdk, AppRelease appRelease) { + com.apptentive.android.sdk.model.SdkAndAppReleasePayload ret = new com.apptentive.android.sdk.model.SdkAndAppReleasePayload(); + if (appRelease == null) { + return ret; + } + + // sdk data + ret.setAuthorEmail(sdk.getAuthorEmail()); + ret.setAuthorName(sdk.getAuthorName()); + ret.setDistribution(sdk.getDistribution()); + ret.setDistributionVersion(sdk.getDistributionVersion()); + ret.setPlatform(sdk.getPlatform()); + ret.setProgrammingLanguage(sdk.getProgrammingLanguage()); + ret.setVersion(sdk.getVersion()); + + + // app release data + ret.setAppStore(appRelease.getAppStore()); + ret.setDebug(appRelease.isDebug()); + ret.setIdentifier(appRelease.getIdentifier()); + ret.setInheritStyle(appRelease.isInheritStyle()); + ret.setOverrideStyle(appRelease.isOverrideStyle()); + ret.setTargetSdkVersion(appRelease.getTargetSdkVersion()); + ret.setType(appRelease.getType()); + ret.setVersionCode(appRelease.getVersionCode()); + ret.setVersionName(appRelease.getVersionName()); + return ret; } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelper.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelper.java index 35c08a82e..2d270e40a 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelper.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -16,10 +16,22 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.model.*; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; -import com.apptentive.android.sdk.module.messagecenter.model.CompoundMessage; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.CompoundMessage; +import com.apptentive.android.sdk.model.JsonPayload; +import com.apptentive.android.sdk.model.Payload; +import com.apptentive.android.sdk.model.PayloadData; +import com.apptentive.android.sdk.model.PayloadType; +import com.apptentive.android.sdk.model.StoredFile; +import com.apptentive.android.sdk.module.messagecenter.MessageManager; import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.storage.legacy.LegacyPayloadFactory; +import com.apptentive.android.sdk.util.Constants; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.Util; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchTask; import org.json.JSONException; import org.json.JSONObject; @@ -27,40 +39,103 @@ import java.io.File; import java.util.ArrayList; import java.util.List; +import java.util.UUID; + +import static com.apptentive.android.sdk.ApptentiveLogTag.DATABASE; +import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; +import static com.apptentive.android.sdk.debug.Assert.assertFalse; +import static com.apptentive.android.sdk.debug.Assert.assertNotNull; +import static com.apptentive.android.sdk.debug.Assert.notNull; +import static com.apptentive.android.sdk.util.Constants.PAYLOAD_DATA_FILE_SUFFIX; /** * There can be only one. SQLiteOpenHelper per database name that is. All new Apptentive tables must be defined here. - * - * @author Sky Kelsey */ public class ApptentiveDatabaseHelper extends SQLiteOpenHelper { - // COMMON - private static final int DATABASE_VERSION = 2; + private static final int DATABASE_VERSION = 3; public static final String DATABASE_NAME = "apptentive"; private static final int TRUE = 1; private static final int FALSE = 0; + private final File fileDir; // data dir of the application + + private final File payloadDataDir; + + //region Payload SQL + + static final class PayloadEntry { + static final String TABLE_NAME = "payload"; + static final DatabaseColumn COLUMN_PRIMARY_KEY = new DatabaseColumn(0, "_id"); + static final DatabaseColumn COLUMN_PAYLOAD_TYPE = new DatabaseColumn(1, "payloadType"); + static final DatabaseColumn COLUMN_IDENTIFIER = new DatabaseColumn(2, "identifier"); + static final DatabaseColumn COLUMN_CONTENT_TYPE = new DatabaseColumn(3, "contentType"); + static final DatabaseColumn COLUMN_AUTH_TOKEN = new DatabaseColumn(4, "authToken"); + static final DatabaseColumn COLUMN_CONVERSATION_ID = new DatabaseColumn(5, "conversationId"); + static final DatabaseColumn COLUMN_REQUEST_METHOD = new DatabaseColumn(6, "requestMethod"); + static final DatabaseColumn COLUMN_PATH = new DatabaseColumn(7, "path"); + static final DatabaseColumn COLUMN_ENCRYPTED = new DatabaseColumn(8, "encrypted"); + static final DatabaseColumn COLUMN_LOCAL_CONVERSATION_ID = new DatabaseColumn(9, "localConversationId"); + } - // PAYLOAD - public static final String TABLE_PAYLOAD = "payload"; - public static final String PAYLOAD_KEY_DB_ID = "_id"; // 0 - public static final String PAYLOAD_KEY_BASE_TYPE = "base_type"; // 1 - public static final String PAYLOAD_KEY_JSON = "json"; // 2 - - private static final String TABLE_CREATE_PAYLOAD = - "CREATE TABLE " + TABLE_PAYLOAD + - " (" + - PAYLOAD_KEY_DB_ID + " INTEGER PRIMARY KEY, " + - PAYLOAD_KEY_BASE_TYPE + " TEXT, " + - PAYLOAD_KEY_JSON + " TEXT" + - ");"; - - public static final String QUERY_PAYLOAD_GET_NEXT_TO_SEND = "SELECT * FROM " + TABLE_PAYLOAD + " ORDER BY " + PAYLOAD_KEY_DB_ID + " ASC LIMIT 1"; + private static final class LegacyPayloadEntry { + static final String TABLE_NAME = "legacy_payload"; + static final DatabaseColumn PAYLOAD_KEY_DB_ID = new DatabaseColumn(0, "_id"); + static final DatabaseColumn PAYLOAD_KEY_BASE_TYPE = new DatabaseColumn(1, "base_type"); + static final DatabaseColumn PAYLOAD_KEY_JSON = new DatabaseColumn(2, "json"); + } - private static final String QUERY_PAYLOAD_GET_ALL_MESSAGE_IN_ORDER = "SELECT * FROM " + TABLE_PAYLOAD + " WHERE " + PAYLOAD_KEY_BASE_TYPE + " = ?" + " ORDER BY " + PAYLOAD_KEY_DB_ID + " ASC"; + private static final String BACKUP_LEGACY_PAYLOAD_TABLE = String.format("ALTER TABLE %s RENAME TO %s;", PayloadEntry.TABLE_NAME, LegacyPayloadEntry.TABLE_NAME); + private static final String DELETE_LEGACY_PAYLOAD_TABLE = String.format("DROP TABLE %s;", LegacyPayloadEntry.TABLE_NAME); + private static final String TABLE_CREATE_PAYLOAD = + "CREATE TABLE " + PayloadEntry.TABLE_NAME + + " (" + + PayloadEntry.COLUMN_PRIMARY_KEY + " INTEGER PRIMARY KEY, " + + PayloadEntry.COLUMN_PAYLOAD_TYPE + " TEXT, " + + PayloadEntry.COLUMN_IDENTIFIER + " TEXT, " + + PayloadEntry.COLUMN_CONTENT_TYPE + " TEXT," + + PayloadEntry.COLUMN_AUTH_TOKEN + " TEXT," + + PayloadEntry.COLUMN_CONVERSATION_ID + " TEXT," + + PayloadEntry.COLUMN_REQUEST_METHOD + " TEXT," + + PayloadEntry.COLUMN_PATH + " TEXT," + + PayloadEntry.COLUMN_ENCRYPTED + " INTEGER," + + PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID + " TEXT" + + ");"; + + private static final String SQL_QUERY_PAYLOAD_LIST_LEGACY = + "SELECT * FROM " + LegacyPayloadEntry.TABLE_NAME + + " ORDER BY " + LegacyPayloadEntry.PAYLOAD_KEY_DB_ID; + + private static final String SQL_QUERY_PAYLOAD_GET_IN_SEND_ORDER = + "SELECT * FROM " + PayloadEntry.TABLE_NAME + + " ORDER BY " + PayloadEntry.COLUMN_PRIMARY_KEY + + " ASC"; + + private static final String SQL_QUERY_UPDATE_INCOMPLETE_PAYLOADS = + "UPDATE " + PayloadEntry.TABLE_NAME + " SET " + + PayloadEntry.COLUMN_AUTH_TOKEN + " = ?, " + + PayloadEntry.COLUMN_CONVERSATION_ID + " = ? " + + "WHERE " + + PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID + " = ? AND " + + PayloadEntry.COLUMN_AUTH_TOKEN + " IS NULL AND " + + PayloadEntry.COLUMN_CONVERSATION_ID + " IS NULL"; + + private static final String SQL_QUERY_REMOVE_INCOMPLETE_PAYLOADS = + "DELETE FROM " + PayloadEntry.TABLE_NAME + " " + + "WHERE " + + PayloadEntry.COLUMN_AUTH_TOKEN + " IS NULL OR " + + PayloadEntry.COLUMN_CONVERSATION_ID + " IS NULL"; + + private static final String SQL_QUERY_PAYLOAD_GET_ALL_MESSAGE_IN_ORDER = + "SELECT * FROM " + PayloadEntry.TABLE_NAME + + " WHERE " + LegacyPayloadEntry.PAYLOAD_KEY_BASE_TYPE + " = ?" + + " ORDER BY " + PayloadEntry.COLUMN_PRIMARY_KEY + + " ASC"; + + //endregion + + //region Message SQL (Deprecated: Used for migration only) - // MESSAGE private static final String TABLE_MESSAGE = "message"; private static final String MESSAGE_KEY_DB_ID = "_id"; // 0 private static final String MESSAGE_KEY_ID = "id"; // 1 @@ -71,24 +146,24 @@ public class ApptentiveDatabaseHelper extends SQLiteOpenHelper { private static final String MESSAGE_KEY_JSON = "json"; // 6 private static final String TABLE_CREATE_MESSAGE = - "CREATE TABLE " + TABLE_MESSAGE + - " (" + - MESSAGE_KEY_DB_ID + " INTEGER PRIMARY KEY, " + - MESSAGE_KEY_ID + " TEXT, " + - MESSAGE_KEY_CLIENT_CREATED_AT + " DOUBLE, " + - MESSAGE_KEY_NONCE + " TEXT, " + - MESSAGE_KEY_STATE + " TEXT, " + - MESSAGE_KEY_READ + " INTEGER, " + - MESSAGE_KEY_JSON + " TEXT" + - ");"; - - private static final String QUERY_MESSAGE_GET_BY_NONCE = "SELECT * FROM " + TABLE_MESSAGE + " WHERE " + MESSAGE_KEY_NONCE + " = ?"; + "CREATE TABLE " + TABLE_MESSAGE + + " (" + + MESSAGE_KEY_DB_ID + " INTEGER PRIMARY KEY, " + + MESSAGE_KEY_ID + " TEXT, " + + MESSAGE_KEY_CLIENT_CREATED_AT + " DOUBLE, " + + MESSAGE_KEY_NONCE + " TEXT, " + + MESSAGE_KEY_STATE + " TEXT, " + + MESSAGE_KEY_READ + " INTEGER, " + + MESSAGE_KEY_JSON + " TEXT" + + ");"; + // Coalesce returns the second arg if the first is null. This forces the entries with null IDs to be ordered last in the list until they do have IDs because they were sent and retrieved from the server. private static final String QUERY_MESSAGE_GET_ALL_IN_ORDER = "SELECT * FROM " + TABLE_MESSAGE + " ORDER BY COALESCE(" + MESSAGE_KEY_ID + ", 'z') ASC"; - private static final String QUERY_MESSAGE_GET_LAST_ID = "SELECT " + MESSAGE_KEY_ID + " FROM " + TABLE_MESSAGE + " WHERE " + MESSAGE_KEY_STATE + " = '" + ApptentiveMessage.State.saved + "' AND " + MESSAGE_KEY_ID + " NOTNULL ORDER BY " + MESSAGE_KEY_ID + " DESC LIMIT 1"; - private static final String QUERY_MESSAGE_UNREAD = "SELECT " + MESSAGE_KEY_ID + " FROM " + TABLE_MESSAGE + " WHERE " + MESSAGE_KEY_READ + " = " + FALSE + " AND " + MESSAGE_KEY_ID + " NOTNULL"; - // FileStore + //endregion + + //region File SQL (Deprecated: Used for migration only) + private static final String TABLE_FILESTORE = "file_store"; private static final String FILESTORE_KEY_ID = "id"; // 0 private static final String FILESTORE_KEY_MIME_TYPE = "mime_type"; // 1 @@ -96,14 +171,18 @@ public class ApptentiveDatabaseHelper extends SQLiteOpenHelper { private static final String FILESTORE_KEY_LOCAL_URL = "local_uri"; // 3 private static final String FILESTORE_KEY_APPTENTIVE_URL = "apptentive_uri"; // 4 private static final String TABLE_CREATE_FILESTORE = - "CREATE TABLE " + TABLE_FILESTORE + - " (" + - FILESTORE_KEY_ID + " TEXT PRIMARY KEY, " + - FILESTORE_KEY_MIME_TYPE + " TEXT, " + - FILESTORE_KEY_ORIGINAL_URL + " TEXT, " + - FILESTORE_KEY_LOCAL_URL + " TEXT, " + - FILESTORE_KEY_APPTENTIVE_URL + " TEXT" + - ");"; + "CREATE TABLE " + TABLE_FILESTORE + + " (" + + FILESTORE_KEY_ID + " TEXT PRIMARY KEY, " + + FILESTORE_KEY_MIME_TYPE + " TEXT, " + + FILESTORE_KEY_ORIGINAL_URL + " TEXT, " + + FILESTORE_KEY_LOCAL_URL + " TEXT, " + + FILESTORE_KEY_APPTENTIVE_URL + " TEXT" + + ");"; + + //endregion + + //region Compound Message FileStore SQL (legacy) /* Compound Message FileStore: * For Compound Messages stored in TABLE_MESSAGE, each associated file will add a row to this table @@ -119,56 +198,40 @@ public class ApptentiveDatabaseHelper extends SQLiteOpenHelper { private static final String COMPOUND_FILESTORE_KEY_CREATION_TIME = "creation_time"; // creation time of the original file // Create the initial table. Use nonce and local cache path as primary key because both sent/received files will have a local cached copy private static final String TABLE_CREATE_COMPOUND_FILESTORE = - "CREATE TABLE " + TABLE_COMPOUND_MESSAGE_FILESTORE + - " (" + - COMPOUND_FILESTORE_KEY_DB_ID + " INTEGER PRIMARY KEY, " + - COMPOUND_FILESTORE_KEY_MESSAGE_NONCE + " TEXT, " + - COMPOUND_FILESTORE_KEY_LOCAL_CACHE_PATH + " TEXT, " + - COMPOUND_FILESTORE_KEY_MIME_TYPE + " TEXT, " + - COMPOUND_FILESTORE_KEY_LOCAL_ORIGINAL_URI + " TEXT, " + - COMPOUND_FILESTORE_KEY_REMOTE_URL + " TEXT, " + - COMPOUND_FILESTORE_KEY_CREATION_TIME + " LONG" + - ");"; + "CREATE TABLE " + TABLE_COMPOUND_MESSAGE_FILESTORE + + " (" + + COMPOUND_FILESTORE_KEY_DB_ID + " INTEGER PRIMARY KEY, " + + COMPOUND_FILESTORE_KEY_MESSAGE_NONCE + " TEXT, " + + COMPOUND_FILESTORE_KEY_LOCAL_CACHE_PATH + " TEXT, " + + COMPOUND_FILESTORE_KEY_MIME_TYPE + " TEXT, " + + COMPOUND_FILESTORE_KEY_LOCAL_ORIGINAL_URI + " TEXT, " + + COMPOUND_FILESTORE_KEY_REMOTE_URL + " TEXT, " + + COMPOUND_FILESTORE_KEY_CREATION_TIME + " LONG" + + ");"; // Query all files associated with a given compound message nonce id private static final String QUERY_MESSAGE_FILES_GET_BY_NONCE = "SELECT * FROM " + TABLE_COMPOUND_MESSAGE_FILESTORE + " WHERE " + COMPOUND_FILESTORE_KEY_MESSAGE_NONCE + " = ?"; - private File fileDir; // data dir of the application - + // endregion - public void ensureClosed(SQLiteDatabase db) { - try { - if (db != null) { - db.close(); - } - } catch (Exception e) { - ApptentiveLog.w("Error closing SQLite database.", e); - } - } - - public void ensureClosed(Cursor cursor) { - try { - if (cursor != null) { - cursor.close(); - } - } catch (Exception e) { - ApptentiveLog.w("Error closing SQLite cursor.", e); - } - } - - public ApptentiveDatabaseHelper(Context context) { + ApptentiveDatabaseHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); - fileDir = context.getFilesDir(); + this.fileDir = context.getFilesDir(); + this.payloadDataDir = new File(fileDir, Constants.PAYLOAD_DATA_DIR); } + //region Create & Upgrade + /** * This function is called only for new installs, and onUpgrade is not called in that case. Therefore, you must include the * latest complete set of DDL here. */ @Override public void onCreate(SQLiteDatabase db) { - ApptentiveLog.d("ApptentiveDatabase.onCreate(db)"); + ApptentiveLog.d(DATABASE, "ApptentiveDatabase.onCreate(db)"); db.execSQL(TABLE_CREATE_PAYLOAD); + + // Leave legacy tables in place for now. db.execSQL(TABLE_CREATE_MESSAGE); db.execSQL(TABLE_CREATE_FILESTORE); db.execSQL(TABLE_CREATE_COMPOUND_FILESTORE); @@ -180,253 +243,16 @@ public void onCreate(SQLiteDatabase db) { */ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - ApptentiveLog.d("ApptentiveDatabase.onUpgrade(db, %d, %d)", oldVersion, newVersion); + ApptentiveLog.d(DATABASE, "ApptentiveDatabase.onUpgrade(db, %d, %d)", oldVersion, newVersion); switch (oldVersion) { case 1: - if (newVersion == 2) { - db.execSQL(TABLE_CREATE_COMPOUND_FILESTORE); - migrateToCompoundMessage(db); - } - } - } - - // PAYLOAD: This table is used to store all the Payloads we want to send to the server. - - /** - * If an item with the same nonce as an item passed in already exists, it is overwritten by the item. Otherwise - * a new message is added. - */ - public void addPayload(Payload... payloads) { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - db.beginTransaction(); - for (Payload payload : payloads) { - ContentValues values = new ContentValues(); - values.put(PAYLOAD_KEY_BASE_TYPE, payload.getBaseType().name()); - values.put(PAYLOAD_KEY_JSON, payload.toString()); - db.insert(TABLE_PAYLOAD, null, values); - } - db.setTransactionSuccessful(); - db.endTransaction(); - - PayloadSendWorker worker = ApptentiveInternal.getInstance().getPayloadWorker(); - if (worker != null) { - worker.setCanRunPayloadThread(true); - } - } catch (SQLException sqe) { - ApptentiveLog.e("addPayload EXCEPTION: " + sqe.getMessage()); - } - } - - public void deletePayload(Payload payload) { - if (payload != null) { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - db.delete(TABLE_PAYLOAD, PAYLOAD_KEY_DB_ID + " = ?", new String[]{Long.toString(payload.getDatabaseId())}); - } catch (SQLException sqe) { - ApptentiveLog.e("deletePayload EXCEPTION: " + sqe.getMessage()); - } - } - } - - public void deleteAllPayloads() { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - db.delete(TABLE_PAYLOAD, "", null); - } catch (SQLException sqe) { - ApptentiveLog.e("deleteAllPayloads EXCEPTION: " + sqe.getMessage()); - } - } - - public Payload getOldestUnsentPayload() { - - SQLiteDatabase db = null; - Cursor cursor = null; - try { - db = getWritableDatabase(); - cursor = db.rawQuery(QUERY_PAYLOAD_GET_NEXT_TO_SEND, null); - Payload payload = null; - if (cursor.moveToFirst()) { - long databaseId = Long.parseLong(cursor.getString(0)); - Payload.BaseType baseType = Payload.BaseType.parse(cursor.getString(1)); - String json = cursor.getString(2); - payload = PayloadFactory.fromJson(json, baseType); - if (payload != null) { - payload.setDatabaseId(databaseId); - } - } - return payload; - } catch (SQLException sqe) { - ApptentiveLog.e("getOldestUnsentPayload EXCEPTION: " + sqe.getMessage()); - return null; - } finally { - ensureClosed(cursor); - } - } - - // MessageStore - - /** - * Saves the message into the message table, and also into the payload table so it can be sent to the server. - */ - public void addOrUpdateMessages(ApptentiveMessage... apptentiveMessages) { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - for (ApptentiveMessage apptentiveMessage : apptentiveMessages) { - Cursor cursor = null; - try { - cursor = db.rawQuery(QUERY_MESSAGE_GET_BY_NONCE, new String[]{apptentiveMessage.getNonce()}); - if (cursor.moveToFirst()) { - // Update - String databaseId = cursor.getString(0); - ContentValues messageValues = new ContentValues(); - messageValues.put(MESSAGE_KEY_ID, apptentiveMessage.getId()); - messageValues.put(MESSAGE_KEY_STATE, apptentiveMessage.getState().name()); - if (apptentiveMessage.isRead()) { // A apptentiveMessage can't be unread after being read. - messageValues.put(MESSAGE_KEY_READ, TRUE); - } - messageValues.put(MESSAGE_KEY_JSON, apptentiveMessage.toString()); - db.update(TABLE_MESSAGE, messageValues, MESSAGE_KEY_DB_ID + " = ?", new String[]{databaseId}); - } else { - // Insert - db.beginTransaction(); - ContentValues messageValues = new ContentValues(); - messageValues.put(MESSAGE_KEY_ID, apptentiveMessage.getId()); - messageValues.put(MESSAGE_KEY_CLIENT_CREATED_AT, apptentiveMessage.getClientCreatedAt()); - messageValues.put(MESSAGE_KEY_NONCE, apptentiveMessage.getNonce()); - messageValues.put(MESSAGE_KEY_STATE, apptentiveMessage.getState().name()); - messageValues.put(MESSAGE_KEY_READ, apptentiveMessage.isRead() ? TRUE : FALSE); - messageValues.put(MESSAGE_KEY_JSON, apptentiveMessage.toString()); - db.insert(TABLE_MESSAGE, null, messageValues); - db.setTransactionSuccessful(); - db.endTransaction(); - } - } finally { - ensureClosed(cursor); - } - } - } catch (SQLException sqe) { - ApptentiveLog.e("addOrUpdateMessages EXCEPTION: " + sqe.getMessage()); - } - } - - public void updateMessage(ApptentiveMessage apptentiveMessage) { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - db.beginTransaction(); - ContentValues values = new ContentValues(); - values.put(MESSAGE_KEY_ID, apptentiveMessage.getId()); - values.put(MESSAGE_KEY_CLIENT_CREATED_AT, apptentiveMessage.getClientCreatedAt()); - values.put(MESSAGE_KEY_NONCE, apptentiveMessage.getNonce()); - values.put(MESSAGE_KEY_STATE, apptentiveMessage.getState().name()); - if (apptentiveMessage.isRead()) { // A apptentiveMessage can't be unread after being read. - values.put(MESSAGE_KEY_READ, TRUE); - } - values.put(MESSAGE_KEY_JSON, apptentiveMessage.toString()); - db.update(TABLE_MESSAGE, values, MESSAGE_KEY_NONCE + " = ?", new String[]{apptentiveMessage.getNonce()}); - db.setTransactionSuccessful(); - } catch (SQLException sqe) { - ApptentiveLog.e("updateMessage EXCEPTION: " + sqe.getMessage()); - } finally { - if (db != null) { - db.endTransaction(); - } + upgradeVersion1to2(db); + case 2: + upgradeVersion2to3(db); } } - public List getAllMessages() { - List apptentiveMessages = new ArrayList(); - SQLiteDatabase db = null; - Cursor cursor = null; - try { - db = getReadableDatabase(); - cursor = db.rawQuery(QUERY_MESSAGE_GET_ALL_IN_ORDER, null); - if (cursor.moveToFirst()) { - do { - String json = cursor.getString(6); - ApptentiveMessage apptentiveMessage = MessageFactory.fromJson(json); - if (apptentiveMessage == null) { - ApptentiveLog.e("Error parsing Record json from database: %s", json); - continue; - } - apptentiveMessage.setDatabaseId(cursor.getLong(0)); - apptentiveMessage.setState(ApptentiveMessage.State.parse(cursor.getString(4))); - apptentiveMessage.setRead(cursor.getInt(5) == TRUE); - apptentiveMessages.add(apptentiveMessage); - } while (cursor.moveToNext()); - } - } catch (SQLException sqe) { - ApptentiveLog.e("getAllMessages EXCEPTION: " + sqe.getMessage()); - } finally { - ensureClosed(cursor); - } - return apptentiveMessages; - } - - public synchronized String getLastReceivedMessageId() { - SQLiteDatabase db = null; - Cursor cursor = null; - String ret = null; - try { - db = getReadableDatabase(); - cursor = db.rawQuery(QUERY_MESSAGE_GET_LAST_ID, null); - if (cursor.moveToFirst()) { - ret = cursor.getString(0); - } - } catch (SQLException sqe) { - ApptentiveLog.e("getLastReceivedMessageId EXCEPTION: " + sqe.getMessage()); - } finally { - ensureClosed(cursor); - } - return ret; - } - - public synchronized int getUnreadMessageCount() { - SQLiteDatabase db = null; - Cursor cursor = null; - try { - db = getWritableDatabase(); - cursor = db.rawQuery(QUERY_MESSAGE_UNREAD, null); - return cursor.getCount(); - } catch (SQLException sqe) { - ApptentiveLog.e("getUnreadMessageCount EXCEPTION: " + sqe.getMessage()); - return 0; - } finally { - ensureClosed(cursor); - } - } - - public synchronized void deleteAllMessages() { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - db.delete(TABLE_MESSAGE, "", null); - } catch (SQLException sqe) { - ApptentiveLog.e("deleteAllMessages EXCEPTION: " + sqe.getMessage()); - } - } - - public synchronized void deleteMessage(String nonce) { - SQLiteDatabase db = null; - try { - db = getWritableDatabase(); - int deleted = db.delete(TABLE_MESSAGE, MESSAGE_KEY_NONCE + " = ?", new String[]{nonce}); - ApptentiveLog.d("Deleted %d messages.", deleted); - } catch (SQLException sqe) { - ApptentiveLog.e("deleteMessage EXCEPTION: " + sqe.getMessage()); - } - } - - - // - // File Store - // - private void migrateToCompoundMessage(SQLiteDatabase db) { + private void upgradeVersion1to2(SQLiteDatabase db) { Cursor cursor = null; // Migrate legacy stored files to compound message associated files try { @@ -456,7 +282,7 @@ private void migrateToCompoundMessage(SQLiteDatabase db) { } while (cursor.moveToNext()); } } catch (SQLException sqe) { - ApptentiveLog.e("migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); + ApptentiveLog.e(DATABASE, "migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); } finally { ensureClosed(cursor); } @@ -466,7 +292,7 @@ private void migrateToCompoundMessage(SQLiteDatabase db) { if (cursor.moveToFirst()) { do { String json = cursor.getString(6); - JSONObject root = null; + JSONObject root; boolean bUpdateRecord = false; try { root = new JSONObject(json); @@ -498,12 +324,12 @@ private void migrateToCompoundMessage(SQLiteDatabase db) { db.update(TABLE_MESSAGE, messageValues, MESSAGE_KEY_DB_ID + " = ?", new String[]{databaseId}); } } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Message: %s", e, json); + ApptentiveLog.v(DATABASE, "Error parsing json as Message: %s", e, json); } } while (cursor.moveToNext()); } } catch (SQLException sqe) { - ApptentiveLog.e("migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); + ApptentiveLog.e(DATABASE, "migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); } finally { ensureClosed(cursor); } @@ -511,7 +337,7 @@ private void migrateToCompoundMessage(SQLiteDatabase db) { // Migrate all pending payload messages // Migrate legacy message types to CompoundMessage Type try { - cursor = db.rawQuery(QUERY_PAYLOAD_GET_ALL_MESSAGE_IN_ORDER, new String[]{Payload.BaseType.message.name()}); + cursor = db.rawQuery(SQL_QUERY_PAYLOAD_GET_ALL_MESSAGE_IN_ORDER, new String[]{PayloadType.message.name()}); if (cursor.moveToFirst()) { do { String json = cursor.getString(2); @@ -541,35 +367,366 @@ private void migrateToCompoundMessage(SQLiteDatabase db) { break; } if (bUpdateRecord) { - String databaseId = cursor.getString(0); + String databaseId = cursor.getString(LegacyPayloadEntry.PAYLOAD_KEY_DB_ID.index); ContentValues messageValues = new ContentValues(); - messageValues.put(PAYLOAD_KEY_JSON, root.toString()); - db.update(TABLE_PAYLOAD, messageValues, PAYLOAD_KEY_DB_ID + " = ?", new String[]{databaseId}); + messageValues.put(LegacyPayloadEntry.PAYLOAD_KEY_JSON.name, root.toString()); + db.update(PayloadEntry.TABLE_NAME, messageValues, PayloadEntry.COLUMN_PRIMARY_KEY + " = ?", new String[]{databaseId}); } } catch (JSONException e) { - ApptentiveLog.v("Error parsing json as Message: %s", e, json); + ApptentiveLog.v(DATABASE, "Error parsing json as Message: %s", e, json); } } while (cursor.moveToNext()); } } catch (SQLException sqe) { - ApptentiveLog.e("migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); + ApptentiveLog.e(DATABASE, "migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); } finally { ensureClosed(cursor); } } - public void deleteAssociatedFiles(String messageNonce) { + /** + * 1. Rename payload table to legacy_payload + * 2. Create new payload table with new columns + * 2. select all payloads in temp_payload + * 3. load each into a the new payload object format + * 4. Save each into the new payload table + * 5. Drop temp_payload + */ + private void upgradeVersion2to3(SQLiteDatabase db) { + ApptentiveLog.i(DATABASE, "Upgrading Database from v2 to v3"); + + Cursor cursor = null; + try { + db.beginTransaction(); + + // 1. Rename existing "payload" table to "legacy_payload" + ApptentiveLog.vv(DATABASE, "\t1. Backing up \"payloads\" database to \"legacy_payloads\""); + db.execSQL(BACKUP_LEGACY_PAYLOAD_TABLE); + + // 2. Create new Payload table as "payload" + ApptentiveLog.vv(DATABASE, "\t2. Creating new \"payloads\" database."); + db.execSQL(TABLE_CREATE_PAYLOAD); + + // 3. Load legacy payloads + ApptentiveLog.vv(DATABASE, "\t3. Loading legacy payloads."); + cursor = db.rawQuery(SQL_QUERY_PAYLOAD_LIST_LEGACY, null); + + ApptentiveLog.vv(DATABASE, "4. Save payloads into new table."); + JsonPayload payload; + while (cursor.moveToNext()) { + PayloadType payloadType = PayloadType.parse(cursor.getString(1)); + String json = cursor.getString(LegacyPayloadEntry.PAYLOAD_KEY_JSON.index); + + payload = LegacyPayloadFactory.createPayload(payloadType, json); + if (payload == null) { + ApptentiveLog.d(DATABASE, "Unable to construct payload of type %s. Continuing.", payloadType.name()); + continue; + } + + // the legacy payload format didn't store 'nonce' in the database so we need to extract if from json + String nonce = payload.optString("nonce", null); + if (nonce == null) { + nonce = UUID.randomUUID().toString(); // if 'nonce' is missing - generate a new one + } + payload.setNonce(nonce); + + // 4. Save each payload in the new table. + ApptentiveLog.vv(DATABASE, "Payload of type %s:, %s", payload.getPayloadType().name(), payload); + ContentValues values = new ContentValues(); + values.put(PayloadEntry.COLUMN_IDENTIFIER.name, notNull(payload.getNonce())); + values.put(PayloadEntry.COLUMN_PAYLOAD_TYPE.name, notNull(payload.getPayloadType().name())); + values.put(PayloadEntry.COLUMN_CONTENT_TYPE.name, notNull(payload.getHttpRequestContentType())); + // The token is encrypted inside the payload body for Logged In Conversations. In that case, don't store it here. + if (!payload.hasEncryptionKey()) { + values.put(PayloadEntry.COLUMN_AUTH_TOKEN.name, payload.getToken()); // might be null + } + values.put(PayloadEntry.COLUMN_CONVERSATION_ID.name, payload.getConversationId()); // might be null + values.put(PayloadEntry.COLUMN_REQUEST_METHOD.name, payload.getHttpRequestMethod().name()); + values.put(PayloadEntry.COLUMN_PATH.name, payload.getHttpEndPoint( + StringUtils.isNullOrEmpty(payload.getConversationId()) ? "${conversationId}" : payload.getConversationId()) // if conversation id is missing we replace it with a place holder and update it later + ); + + File dest = getPayloadBodyFile(payload.getNonce()); + ApptentiveLog.v(DATABASE, "Saving payload body to: %s", dest); + Util.writeBytes(dest, payload.renderData()); + + values.put(PayloadEntry.COLUMN_ENCRYPTED.name, payload.hasEncryptionKey() ? TRUE : FALSE); + + db.insert(PayloadEntry.TABLE_NAME, null, values); + } + + // 5. Migrate messages + ApptentiveLog.vv(DATABASE, "\t6. Migrating messages."); + migrateMessages(db); + + // 6. Finally, delete the temporary legacy table + ApptentiveLog.vv(DATABASE, "\t6. Delete temporary \"legacy_payloads\" database."); + db.execSQL(DELETE_LEGACY_PAYLOAD_TABLE); + db.setTransactionSuccessful(); + } catch (Exception e) { + ApptentiveLog.e(DATABASE, e, "Error in upgradeVersion2to3()"); + } finally { + ensureClosed(cursor); + if (db != null) { + db.endTransaction(); + } + } + } + + private void migrateMessages(SQLiteDatabase db) { + try { + final List messages = getAllMessages(db); + DispatchQueue.mainQueue().dispatchAsync(new DispatchTask() { + @Override + protected void execute() { + MessageManager messageManager = ApptentiveInternal.getInstance().getMessageManager(); + assertNotNull(messageManager, "Can't migrate messages: message manager is not initialized"); + if (messageManager != null) { + messageManager.addMessages(messages.toArray(new ApptentiveMessage[messages.size()])); + } + } + }); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while trying to migrate messages"); + } + } + + private List getAllMessages(SQLiteDatabase db) { + List messages = new ArrayList<>(); + Cursor cursor = null; + try { + cursor = db.rawQuery(QUERY_MESSAGE_GET_ALL_IN_ORDER, null); + while (cursor.moveToNext()) { + String json = cursor.getString(6); + ApptentiveMessage message = MessageFactory.fromJson(json); + if (message == null) { + ApptentiveLog.e("Error parsing Record json from database: %s", json); + continue; + } + message.setId(cursor.getString(1)); + message.setCreatedAt(cursor.getDouble(2)); + message.setNonce(cursor.getString(3)); + message.setState(ApptentiveMessage.State.parse(cursor.getString(4))); + message.setRead(cursor.getInt(5) == TRUE); + messages.add(message); + } + } finally { + ensureClosed(cursor); + } + return messages; + } + + + //endregion + + //region Payloads + + /** + * If an item with the same nonce as an item passed in already exists, it is overwritten by the item. Otherwise + * a new message is added. + */ + void addPayload(Payload payload) { + SQLiteDatabase db = null; + try { + db = getWritableDatabase(); + db.beginTransaction(); + + ContentValues values = new ContentValues(); + values.put(PayloadEntry.COLUMN_IDENTIFIER.name, notNull(payload.getNonce())); + values.put(PayloadEntry.COLUMN_PAYLOAD_TYPE.name, notNull(payload.getPayloadType().name())); + values.put(PayloadEntry.COLUMN_CONTENT_TYPE.name, notNull(payload.getHttpRequestContentType())); + // The token is encrypted inside the payload body for Logged In Conversations. In that case, don't store it here. + if (!payload.hasEncryptionKey()) { + values.put(PayloadEntry.COLUMN_AUTH_TOKEN.name, payload.getToken()); // might be null + } + values.put(PayloadEntry.COLUMN_CONVERSATION_ID.name, payload.getConversationId()); // might be null + values.put(PayloadEntry.COLUMN_REQUEST_METHOD.name, payload.getHttpRequestMethod().name()); + values.put(PayloadEntry.COLUMN_PATH.name, payload.getHttpEndPoint( + StringUtils.isNullOrEmpty(payload.getConversationId()) ? "${conversationId}" : payload.getConversationId()) // if conversation id is missing we replace it with a place holder and update it later + ); + + File dest = getPayloadBodyFile(payload.getNonce()); + ApptentiveLog.v(DATABASE, "Saving payload body to: %s", dest); + Util.writeBytes(dest, payload.renderData()); + + values.put(PayloadEntry.COLUMN_ENCRYPTED.name, payload.hasEncryptionKey() ? TRUE : FALSE); + values.put(PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID.name, notNull(payload.getLocalConversationIdentifier())); + + db.insert(PayloadEntry.TABLE_NAME, null, values); + db.setTransactionSuccessful(); + } catch (Exception e) { + ApptentiveLog.e(DATABASE, e, "Error adding payload."); + } finally { + if (db != null) { + db.endTransaction(); + } + } + + if (ApptentiveLog.canLog(ApptentiveLog.Level.VERY_VERBOSE)) { + printPayloadTable("Added payload"); + } + } + + void deletePayload(String payloadIdentifier) { + if (payloadIdentifier == null) { + throw new IllegalArgumentException("Payload identifier is null"); + } + // First delete the row + SQLiteDatabase db; + try { + db = getWritableDatabase(); + db.delete( + PayloadEntry.TABLE_NAME, + PayloadEntry.COLUMN_IDENTIFIER + " = ?", + new String[]{payloadIdentifier} + ); + } catch (SQLException sqe) { + ApptentiveLog.e(DATABASE, "deletePayload EXCEPTION: " + sqe.getMessage()); + } + + // Then delete the data file + File dest = getPayloadBodyFile(payloadIdentifier); + ApptentiveLog.v(DATABASE, "Deleted payload \"%s\" data file successfully? %b", payloadIdentifier, dest.delete()); + + if (ApptentiveLog.canLog(ApptentiveLog.Level.VERY_VERBOSE)) { + printPayloadTable("Deleted payload"); + } + } + + void deleteAllPayloads() { + // FIXME: Delete files too. + SQLiteDatabase db; + try { + db = getWritableDatabase(); + db.delete(PayloadEntry.TABLE_NAME, "", null); + } catch (SQLException sqe) { + ApptentiveLog.e(DATABASE, "deleteAllPayloads EXCEPTION: " + sqe.getMessage()); + } + } + + PayloadData getOldestUnsentPayload() { + if (ApptentiveLog.canLog(ApptentiveLog.Level.VERY_VERBOSE)) { + printPayloadTable("getOldestUnsentPayload"); + } + + SQLiteDatabase db; + Cursor cursor = null; + try { + db = getWritableDatabase(); + cursor = db.rawQuery(SQL_QUERY_PAYLOAD_GET_IN_SEND_ORDER, null); + int count = cursor.getCount(); + ApptentiveLog.v(PAYLOADS, "Unsent payloads count: %d", count); + + while(cursor.moveToNext()) { + final String conversationId = cursor.getString(PayloadEntry.COLUMN_CONVERSATION_ID.index); + if (conversationId == null) { + ApptentiveLog.d(PAYLOADS, "Oldest unsent payload is missing a conversation id"); + return null; + } + + final String authToken = cursor.getString(PayloadEntry.COLUMN_AUTH_TOKEN.index); + final String nonce = notNull(cursor.getString(PayloadEntry.COLUMN_IDENTIFIER.index)); + + final PayloadType payloadType = PayloadType.parse(cursor.getString(PayloadEntry.COLUMN_PAYLOAD_TYPE.index)); + assertFalse(PayloadType.unknown.equals(payloadType), "Oldest unsent payload has unknown type"); + + if (PayloadType.unknown.equals(payloadType)) { + deletePayload(nonce); + continue; + } + + final String httpRequestPath = updatePayloadRequestPath(cursor.getString(PayloadEntry.COLUMN_PATH.index), conversationId); + + // TODO: We need a migration for existing payload bodies to put them into files. + + File file = getPayloadBodyFile(nonce); + if (!file.exists()) { + ApptentiveLog.w("Oldest unsent payload had no data file. Deleting."); + deletePayload(nonce); + continue; + } + byte[] data = Util.readBytes(file); + final String contentType = notNull(cursor.getString(PayloadEntry.COLUMN_CONTENT_TYPE.index)); + final HttpRequestMethod httpRequestMethod = HttpRequestMethod.valueOf(notNull(cursor.getString(PayloadEntry.COLUMN_REQUEST_METHOD.index))); + final boolean encrypted = cursor.getInt(PayloadEntry.COLUMN_ENCRYPTED.index) == TRUE; + return new PayloadData(payloadType, nonce, conversationId, data, authToken, contentType, httpRequestPath, httpRequestMethod, encrypted); + } + return null; + } catch (Exception e) { + ApptentiveLog.e(e, "Error getting oldest unsent payload."); + return null; + } finally { + ensureClosed(cursor); + } + } + + private String updatePayloadRequestPath(String path, String conversationId) { + return path.replace("${conversationId}", conversationId); + } + + void updateIncompletePayloads(String conversationId, String authToken, String localConversationId) { + if (ApptentiveLog.canLog(ApptentiveLog.Level.VERY_VERBOSE)) { + printPayloadTable("updateIncompletePayloads BEFORE"); + } + + if (StringUtils.isNullOrEmpty(conversationId)) { + throw new IllegalArgumentException("Conversation id is null or empty"); + } + if (StringUtils.isNullOrEmpty(authToken)) { + throw new IllegalArgumentException("Token is null or empty"); + } + Cursor cursor = null; + try { + SQLiteDatabase db = getWritableDatabase(); + cursor = db.rawQuery(SQL_QUERY_UPDATE_INCOMPLETE_PAYLOADS, new String[] { + authToken, conversationId, localConversationId + }); + cursor.moveToFirst(); // we need to move a cursor in order to update database + ApptentiveLog.v(DATABASE, "Updated missing conversation ids"); + } catch (SQLException e) { + ApptentiveLog.e(e, "Exception while updating missing conversation ids"); + } finally { + ensureClosed(cursor); + } + + // remove incomplete payloads which don't belong to an active conversation + removeCorruptedPayloads(); + + if (ApptentiveLog.canLog(ApptentiveLog.Level.VERY_VERBOSE)) { + printPayloadTable("updateIncompletePayloads AFTER"); + } + } + + private void removeCorruptedPayloads() { + Cursor cursor = null; + try { + SQLiteDatabase db = getWritableDatabase(); + cursor = db.rawQuery(SQL_QUERY_REMOVE_INCOMPLETE_PAYLOADS, null); + cursor.moveToFirst(); // we need to move a cursor in order to update database + ApptentiveLog.v(DATABASE, "Removed incomplete payloads"); + } catch (SQLException e) { + ApptentiveLog.e(e, "Exception while removing incomplete payloads"); + } finally { + ensureClosed(cursor); + } + } + + //endregion + + //region Files + + void deleteAssociatedFiles(String messageNonce) { SQLiteDatabase db = null; try { db = getWritableDatabase(); int deleted = db.delete(TABLE_COMPOUND_MESSAGE_FILESTORE, COMPOUND_FILESTORE_KEY_MESSAGE_NONCE + " = ?", new String[]{messageNonce}); - ApptentiveLog.d("Deleted %d stored files.", deleted); + ApptentiveLog.d(DATABASE, "Deleted %d stored files.", deleted); } catch (SQLException sqe) { - ApptentiveLog.e("deleteAssociatedFiles EXCEPTION: " + sqe.getMessage()); + ApptentiveLog.e(DATABASE, "deleteAssociatedFiles EXCEPTION: " + sqe.getMessage()); } } - public List getAssociatedFiles(String nonce) { + List getAssociatedFiles(String nonce) { SQLiteDatabase db = null; Cursor cursor = null; List associatedFiles = new ArrayList(); @@ -590,7 +747,7 @@ public List getAssociatedFiles(String nonce) { } while (cursor.moveToNext()); } } catch (SQLException sqe) { - ApptentiveLog.e("getAssociatedFiles EXCEPTION: " + sqe.getMessage()); + ApptentiveLog.e(DATABASE, "getAssociatedFiles EXCEPTION: " + sqe.getMessage()); } finally { ensureClosed(cursor); } @@ -603,7 +760,7 @@ public List getAssociatedFiles(String nonce) { * @param associatedFiles list of associated files * @return true if succeed */ - public boolean addCompoundMessageFiles(List associatedFiles) { + boolean addCompoundMessageFiles(List associatedFiles) { String messageNonce = associatedFiles.get(0).getId(); SQLiteDatabase db = null; long ret = -1; @@ -627,18 +784,109 @@ public boolean addCompoundMessageFiles(List associatedFiles) { db.setTransactionSuccessful(); db.endTransaction(); } catch (SQLException sqe) { - ApptentiveLog.e("addCompoundMessageFiles EXCEPTION: " + sqe.getMessage()); - } finally { - return ret != -1; + ApptentiveLog.e(DATABASE, "addCompoundMessageFiles EXCEPTION: " + sqe.getMessage()); } + return ret != -1; + } + + //endregion + + // region Helpers + + private File getPayloadBodyFile(String nonce) { + return new File(payloadDataDir, nonce + PAYLOAD_DATA_FILE_SUFFIX); } - public void reset(Context context) { - /** - * The following ONLY be used during development and testing. It will delete the database, including all saved - * payloads, messages, and files. - */ + private void ensureClosed(Cursor cursor) { + try { + if (cursor != null) { + cursor.close(); + } + } catch (Exception e) { + ApptentiveLog.w(DATABASE, "Error closing SQLite cursor.", e); + } + } + + /** + * The following shall ONLY be used during development and testing. It will delete the database, + * including all saved payloads, messages, and files. + */ + void reset(Context context) { context.deleteDatabase(DATABASE_NAME); } -} + //endregion + + //region Helper classes + + private static final class DatabaseColumn { + public final String name; + final int index; + + DatabaseColumn(int index, String name) { + this.index = index; + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + //endregion + + //region Debug + + private void printPayloadTable(String title) { + SQLiteDatabase db; + Cursor cursor = null; + try { + db = getWritableDatabase(); + cursor = db.rawQuery(SQL_QUERY_PAYLOAD_GET_IN_SEND_ORDER, null); + int payloadCount = cursor.getCount(); + if (payloadCount == 0) { + ApptentiveLog.vv(PAYLOADS, "%s (%d payload(s))", title, payloadCount); + return; + } + + Object[][] rows = new Object[1 + payloadCount][]; + rows[0] = new Object[] { + PayloadEntry.COLUMN_PRIMARY_KEY, + PayloadEntry.COLUMN_PAYLOAD_TYPE, + PayloadEntry.COLUMN_IDENTIFIER, + PayloadEntry.COLUMN_CONTENT_TYPE, + PayloadEntry.COLUMN_CONVERSATION_ID, + PayloadEntry.COLUMN_REQUEST_METHOD, + PayloadEntry.COLUMN_PATH, + PayloadEntry.COLUMN_ENCRYPTED, + PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID, + PayloadEntry.COLUMN_AUTH_TOKEN + }; + + int index = 1; + while(cursor.moveToNext()) { + + rows[index++] = new Object[] { + cursor.getInt(PayloadEntry.COLUMN_PRIMARY_KEY.index), + cursor.getString(PayloadEntry.COLUMN_PAYLOAD_TYPE.index), + cursor.getString(PayloadEntry.COLUMN_IDENTIFIER.index), + cursor.getString(PayloadEntry.COLUMN_CONTENT_TYPE.index), + cursor.getString(PayloadEntry.COLUMN_CONVERSATION_ID.index), + cursor.getString(PayloadEntry.COLUMN_REQUEST_METHOD.index), + cursor.getString(PayloadEntry.COLUMN_PATH.index), + cursor.getInt(PayloadEntry.COLUMN_ENCRYPTED.index), + cursor.getString(PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID.index), + cursor.getString(PayloadEntry.COLUMN_AUTH_TOKEN.index) + }; + } + ApptentiveLog.vv(PAYLOADS, "%s (%d payload(s)):\n%s", title, payloadCount, StringUtils.table(rows)); + } catch (Exception ignored) { + ignored.printStackTrace(); + } finally { + ensureClosed(cursor); + } + } + + //endregion +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveTaskManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveTaskManager.java index bf014f0c6..705c5d6b5 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveTaskManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/ApptentiveTaskManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -8,9 +8,20 @@ import android.content.Context; +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.comm.ApptentiveHttpClient; +import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.model.Payload; +import com.apptentive.android.sdk.model.PayloadData; import com.apptentive.android.sdk.model.StoredFile; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; +import com.apptentive.android.sdk.network.HttpRequestRetryPolicyDefault; +import com.apptentive.android.sdk.notifications.ApptentiveNotification; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationCenter; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationObserver; +import com.apptentive.android.sdk.util.threading.DispatchQueue; +import com.apptentive.android.sdk.util.threading.DispatchTask; + +import org.json.JSONObject; import java.util.List; import java.util.concurrent.Callable; @@ -19,16 +30,37 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_APP_ENTERED_BACKGROUND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_APP_ENTERED_FOREGROUND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_CONVERSATION_STATE_DID_CHANGE; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_CONVERSATION; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_PAYLOAD; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_RESPONSE_CODE; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_RESPONSE_DATA; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_SUCCESSFUL; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_PAYLOAD_DID_FINISH_SEND; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_PAYLOAD_WILL_START_SEND; +import static com.apptentive.android.sdk.conversation.ConversationState.ANONYMOUS; +import static com.apptentive.android.sdk.conversation.ConversationState.UNDEFINED; +import static com.apptentive.android.sdk.debug.Assert.assertNotEquals; +import static com.apptentive.android.sdk.debug.Assert.assertNotNull; +import static com.apptentive.android.sdk.debug.Assert.notNull; +import static java.lang.Boolean.FALSE; +import static java.lang.Boolean.TRUE; + +public class ApptentiveTaskManager implements PayloadStore, EventStore, ApptentiveNotificationObserver, PayloadSender.Listener { -public class ApptentiveTaskManager implements PayloadStore, EventStore, MessageStore { + private final ApptentiveDatabaseHelper dbHelper; + private final ThreadPoolExecutor singleThreadExecutor; // TODO: replace with a private concurrent dispatch queue - private ApptentiveDatabaseHelper dbHelper; - private ThreadPoolExecutor singleThreadExecutor; + private final PayloadSender payloadSender; + private boolean appInBackground; /* * Creates an asynchronous task manager with one worker thread. This constructor must be invoked on the UI thread. */ - public ApptentiveTaskManager(Context context) { + public ApptentiveTaskManager(Context context, ApptentiveHttpClient apptentiveHttpClient) { dbHelper = new ApptentiveDatabaseHelper(context); /* When a new database task is submitted, the executor has the following behaviors: * 1. If the thread pool has no thread yet, it creates a single worker thread. @@ -37,50 +69,59 @@ public ApptentiveTaskManager(Context context) { * */ singleThreadExecutor = new ThreadPoolExecutor(1, 1, - 30L, TimeUnit.SECONDS, - new LinkedBlockingQueue(), - new ThreadPoolExecutor.CallerRunsPolicy()); + 30L, TimeUnit.SECONDS, + new LinkedBlockingQueue(), + new ThreadPoolExecutor.CallerRunsPolicy()); // If no new task arrives in 30 seconds, the worker thread terminates; otherwise it will be reused singleThreadExecutor.allowCoreThreadTimeOut(true); - } - - - /* Wrapper class that can be used to return worker thread result to caller through message - * Usage: Message message = callerThreadHandler.obtainMessage(MESSAGE_FINISH, - * new AsyncTaskExResult>(ApptentiveTaskManager.this, result)); - * message.sendToTarget(); - */ - @SuppressWarnings({"RawUseOfParameterizedType"}) - private static class ApptentiveTaskResult { - final ApptentiveTaskManager mTask; - final Data[] mData; + + // Create payload sender object with a custom 'retry' policy + payloadSender = new PayloadSender(apptentiveHttpClient, new HttpRequestRetryPolicyDefault() { + @Override + public boolean shouldRetryRequest(int responseCode, int retryAttempt) { + return false; // don't use built-in retry logic for payloads since payload sender listener + // would handle it properly + } + }); + payloadSender.setListener(this); - ApptentiveTaskResult(ApptentiveTaskManager task, Data... data) { - mTask = task; - mData = data; - } + ApptentiveNotificationCenter.defaultCenter() + .addObserver(NOTIFICATION_CONVERSATION_STATE_DID_CHANGE, this) + .addObserver(NOTIFICATION_APP_ENTERED_BACKGROUND, this) + .addObserver(NOTIFICATION_APP_ENTERED_FOREGROUND, this); } /** * If an item with the same nonce as an item passed in already exists, it is overwritten by the item. Otherwise * a new message is added. */ - public void addPayload(final Payload... payloads) { + public void addPayload(final Payload payload) { + ApptentiveLog.v(PAYLOADS, "Adding payload: %s", payload); singleThreadExecutor.execute(new Runnable() { @Override public void run() { - dbHelper.addPayload(payloads); + try { + dbHelper.addPayload(payload); + sendNextPayloadSync(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while adding a payload: %s", payload); + } } }); } - public void deletePayload(final Payload payload){ - if (payload != null) { + public void deletePayload(final String payloadIdentifier) { + if (payloadIdentifier != null) { singleThreadExecutor.execute(new Runnable() { @Override public void run() { - dbHelper.deletePayload(payload); + try { + dbHelper.deletePayload(payloadIdentifier); + sendNextPayloadSync(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while deleting a payload: %s", payloadIdentifier); + } } }); } @@ -90,120 +131,193 @@ public void deleteAllPayloads() { singleThreadExecutor.execute(new Runnable() { @Override public void run() { - dbHelper.deleteAllPayloads(); + try { + dbHelper.deleteAllPayloads(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while deleting all payloads"); + } } }); } - public synchronized Future getOldestUnsentPayload() throws Exception { - return singleThreadExecutor.submit(new Callable() { - @Override - public Payload call() throws Exception { - return dbHelper.getOldestUnsentPayload(); - } - }); + private PayloadData getOldestUnsentPayloadSync() { + return dbHelper.getOldestUnsentPayload(); } - @Override - public void addOrUpdateMessages(final ApptentiveMessage... apptentiveMessages) { + public void deleteAssociatedFiles(final String messageNonce) { singleThreadExecutor.execute(new Runnable() { @Override public void run() { - dbHelper.addOrUpdateMessages(apptentiveMessages); + try { + dbHelper.deleteAssociatedFiles(messageNonce); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while deleting associated file: %s", messageNonce); + } } }); } - @Override - public void updateMessage(final ApptentiveMessage apptentiveMessage) { - singleThreadExecutor.execute(new Runnable() { + public Future> getAssociatedFiles(final String nonce) throws Exception { + return singleThreadExecutor.submit(new Callable>() { @Override - public void run() { - dbHelper.updateMessage(apptentiveMessage); + public List call() throws Exception { + return dbHelper.getAssociatedFiles(nonce); } }); } - @Override - public Future> getAllMessages() throws Exception { - return singleThreadExecutor.submit(new Callable>() { + public Future addCompoundMessageFiles(final List associatedFiles) throws Exception { + return singleThreadExecutor.submit(new Callable() { @Override - public List call() throws Exception { - List result = dbHelper.getAllMessages(); - return result; + public Boolean call() throws Exception { + return dbHelper.addCompoundMessageFiles(associatedFiles); } }); } - @Override - public Future getLastReceivedMessageId() throws Exception { - return singleThreadExecutor.submit(new Callable() { - @Override - public String call() throws Exception { - return dbHelper.getLastReceivedMessageId(); - } - }); + public void reset(Context context) { + dbHelper.reset(context); } - @Override - public Future getUnreadMessageCount() throws Exception { - return singleThreadExecutor.submit(new Callable() { - @Override - public Integer call() throws Exception { - return dbHelper.getUnreadMessageCount(); - } - }); - } + //region PayloadSender.Listener @Override - public void deleteAllMessages() { - singleThreadExecutor.execute(new Runnable() { - @Override - public void run() { - dbHelper.deleteAllMessages(); - } - }); - } + public void onFinishSending(PayloadSender sender, PayloadData payload, boolean cancelled, String errorMessage, int responseCode, JSONObject responseData) { + ApptentiveNotificationCenter.defaultCenter() + .postNotification(NOTIFICATION_PAYLOAD_DID_FINISH_SEND, + NOTIFICATION_KEY_PAYLOAD, payload, + NOTIFICATION_KEY_SUCCESSFUL, errorMessage == null && !cancelled ? TRUE : FALSE, + NOTIFICATION_KEY_RESPONSE_CODE, responseCode, + NOTIFICATION_KEY_RESPONSE_DATA, responseData); - @Override - public void deleteMessage(final String nonce) { - singleThreadExecutor.execute(new Runnable() { - @Override - public void run() { - dbHelper.deleteMessage(nonce); - } - }); - } + if (cancelled) { + ApptentiveLog.v(PAYLOADS, "Payload sending was cancelled: %s", payload); + return; // don't remove cancelled payloads from the queue + } - public void deleteAssociatedFiles(final String messageNonce) { - singleThreadExecutor.execute(new Runnable() { - @Override - public void run() { - dbHelper.deleteAssociatedFiles(messageNonce); + if (errorMessage != null) { + ApptentiveLog.e(PAYLOADS, "Payload sending failed: %s\n%s", payload, errorMessage); + if (appInBackground) { + ApptentiveLog.v(PAYLOADS, "The app went to the background so we won't remove the payload from the queue"); + retrySending(5000); + return; + } else if (responseCode == -1) { + ApptentiveLog.v(PAYLOADS, "Payload failed to send due to a connection error."); + retrySending(5000); + return; + } else if (responseCode > 500) { + ApptentiveLog.v(PAYLOADS, "Payload failed to send due to a server error."); + retrySending(5000); + return; } - }); + } else { + ApptentiveLog.v(PAYLOADS, "Payload was successfully sent: %s", payload); + } + + // Only let the payload be deleted if it was successfully sent, or got an unrecoverable client error. + deletePayload(payload.getNonce()); } - public Future> getAssociatedFiles(final String nonce) throws Exception { - return singleThreadExecutor.submit(new Callable>() { + private void retrySending(long delayMillis) { + ApptentiveLog.d(PAYLOADS, "Retry sending payloads in %d ms", delayMillis); + DispatchQueue.backgroundQueue().dispatchAsync(new DispatchTask() { @Override - public List call() throws Exception { - return dbHelper.getAssociatedFiles(nonce); + protected void execute() { + singleThreadExecutor.execute(new Runnable() { + @Override + public void run() { + try { + ApptentiveLog.d(PAYLOADS, "Retrying sending payloads"); + sendNextPayloadSync(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while trying to retry sending payloads"); + } + } + }); } - }); + }, delayMillis); } - public Future addCompoundMessageFiles(final List associatedFiles) throws Exception{ - return singleThreadExecutor.submit(new Callable() { + //endregion + + //region Payload Sending + private void sendNextPayload() { + DispatchQueue.backgroundQueue().dispatchAsync(new DispatchTask() { @Override - public Boolean call() throws Exception { - return dbHelper.addCompoundMessageFiles(associatedFiles); + protected void execute() { + sendNextPayloadSync(); } }); } - public void reset(Context context) { - dbHelper.reset(context); + private void sendNextPayloadSync() { + if (appInBackground) { + ApptentiveLog.v(PAYLOADS, "Can't send the next payload: the app is in the background"); + return; + } + + if (payloadSender.isSendingPayload()) { + ApptentiveLog.v(PAYLOADS, "Can't send the next payload: payload sender is busy"); + return; + } + + final PayloadData payload; + try { + payload = getOldestUnsentPayloadSync(); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while peeking the next payload for sending"); + return; + } + + if (payload == null) { + return; + } + + boolean scheduled = payloadSender.sendPayload(payload); + + // if payload sending was scheduled - notify the rest of the SDK + if (scheduled) { + ApptentiveNotificationCenter.defaultCenter() + .postNotification(NOTIFICATION_PAYLOAD_WILL_START_SEND, NOTIFICATION_KEY_PAYLOAD, payload); + } } + //endregion + + @Override + public void onReceiveNotification(ApptentiveNotification notification) { + if (notification.hasName(NOTIFICATION_CONVERSATION_STATE_DID_CHANGE)) { + final Conversation conversation = notification.getUserInfo(NOTIFICATION_KEY_CONVERSATION, Conversation.class); + assertNotNull(conversation); // sanity check + assertNotEquals(conversation.getState(), UNDEFINED); + if (conversation.hasActiveState()) { + final String conversationId = notNull(conversation.getConversationId()); + final String conversationToken = notNull(conversation.getConversationToken()); + final String conversationLocalIdentifier = notNull(conversation.getLocalIdentifier()); + + ApptentiveLog.d("Conversation %s state changed to %s.", conversationId, conversation.getState()); + // when the Conversation ID comes back from the server, we need to update + // the payloads that may have already been enqueued so + // that they each have the Conversation ID. + if (conversation.hasState(ANONYMOUS)) { + singleThreadExecutor.execute(new Runnable() { + @Override + public void run() { + try { + dbHelper.updateIncompletePayloads(conversationId, conversationToken, conversationLocalIdentifier); + sendNextPayloadSync(); // after we've updated payloads - we need to send them + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while trying to update incomplete payloads"); + } + } + }); + } + } + } else if (notification.hasName(NOTIFICATION_APP_ENTERED_FOREGROUND)) { + appInBackground = false; + sendNextPayload(); // when the app comes back from the background - we need to resume sending payloads + } else if (notification.hasName(NOTIFICATION_APP_ENTERED_BACKGROUND)) { + appInBackground = true; + } + } } \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/CustomData.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/CustomData.java new file mode 100644 index 000000000..e49c4e0ff --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/CustomData.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.ApptentiveLog; + +import org.json.JSONException; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +public class CustomData extends HashMap implements Saveable { + + private static final long serialVersionUID = 1L; + + //region Listeners + private transient DataChangedListener listener; + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + //endregion + + + //region Saving when modified + @Override + public Serializable put(String key, Serializable value) { + Serializable ret = super.put(key, value); + notifyDataChanged(); + return ret; + } + + @Override + public void putAll(Map m) { + super.putAll(m); + notifyDataChanged(); + } + + @Override + public Serializable remove(Object key) { + Serializable ret = super.remove(key); + notifyDataChanged(); + return ret; + } + + @Override + public void clear() { + super.clear(); + notifyDataChanged(); + } + //endregion + + public com.apptentive.android.sdk.model.CustomData toJson() { + try { + com.apptentive.android.sdk.model.CustomData ret = new com.apptentive.android.sdk.model.CustomData(); + Set keys = keySet(); + for (String key : keys) { + ret.put(key, get(key)); + } + return ret; + } catch (JSONException e) { + ApptentiveLog.e(e, "Exception while creating custom data"); + } + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DataChangedListener.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DataChangedListener.java new file mode 100644 index 000000000..f7ecb24a1 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DataChangedListener.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +public interface DataChangedListener { + void onDataChanged(); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/Device.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Device.java new file mode 100644 index 000000000..8859e51a5 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Device.java @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import android.text.TextUtils; + +public class Device implements Saveable, DataChangedListener { + + private static final long serialVersionUID = 1L; + + private String uuid; + private String osName; + private String osVersion; + private String osBuild; + private int osApiLevel; + private String manufacturer; + private String model; + private String board; + private String product; + private String brand; + private String cpu; + private String device; + private String carrier; + private String currentCarrier; + private String networkType; + private String buildType; + private String buildId; + private String bootloaderVersion; + private String radioVersion; + private CustomData customData; + private String localeCountryCode; + private String localeLanguageCode; + private String localeRaw; + private String utcOffset; + private IntegrationConfig integrationConfig; + + private transient DataChangedListener listener; + + public Device() { + customData = new CustomData(); + integrationConfig = new IntegrationConfig(); + } + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + customData.setDataChangedListener(this); + integrationConfig.setDataChangedListener(this); + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + @Override + public void onDataChanged() { + notifyDataChanged(); + } + + // TODO: unit tests + public Device clone() { + Device clone = new Device(); + clone.uuid = uuid; + clone.osName = osName; + clone.osVersion = osVersion; + clone.osBuild = osBuild; + clone.osApiLevel = osApiLevel; + clone.manufacturer = manufacturer; + clone.model = model; + clone.board = board; + clone.product = product; + clone.brand = brand; + clone.cpu = cpu; + clone.device = device; + clone.carrier = carrier; + clone.currentCarrier = currentCarrier; + clone.networkType = networkType; + clone.buildType = buildType; + clone.buildId = buildId; + clone.bootloaderVersion = bootloaderVersion; + clone.radioVersion = radioVersion; + if (customData != null) { + clone.customData.putAll(customData); + } + clone.localeCountryCode = localeCountryCode; + clone.localeLanguageCode = localeLanguageCode; + clone.localeRaw = localeRaw; + clone.utcOffset = utcOffset; + if (integrationConfig != null) { + clone.integrationConfig = integrationConfig.clone(); + } + clone.listener = listener; + return clone; + } + + //region Getters & Setters + + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + if (!TextUtils.equals(this.uuid, uuid)) { + this.uuid = uuid; + notifyDataChanged(); + } + } + + public String getOsName() { + return osName; + } + + public void setOsName(String osName) { + if (!TextUtils.equals(this.osName, osName)) { + this.osName = osName; + notifyDataChanged(); + } + } + + public String getOsVersion() { + return osVersion; + } + + public void setOsVersion(String osVersion) { + if (!TextUtils.equals(this.osVersion, osVersion)) { + this.osVersion = osVersion; + notifyDataChanged(); + } + } + + public String getOsBuild() { + return osBuild; + } + + public void setOsBuild(String osBuild) { + if (!TextUtils.equals(this.osBuild, osBuild)) { + this.osBuild = osBuild; + notifyDataChanged(); + } + } + + public int getOsApiLevel() { + return osApiLevel; + } + + public void setOsApiLevel(int osApiLevel) { + if (this.osApiLevel != osApiLevel) { + this.osApiLevel = osApiLevel; + notifyDataChanged(); + } + } + + public String getManufacturer() { + return manufacturer; + } + + public void setManufacturer(String manufacturer) { + if (!TextUtils.equals(this.manufacturer, manufacturer)) { + this.manufacturer = manufacturer; + notifyDataChanged(); + } + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + if (!TextUtils.equals(this.model, model)) { + this.model = model; + notifyDataChanged(); + } + } + + public String getBoard() { + return board; + } + + public void setBoard(String board) { + if (!TextUtils.equals(this.board, board)) { + this.board = board; + notifyDataChanged(); + } + } + + public String getProduct() { + return product; + } + + public void setProduct(String product) { + if (!TextUtils.equals(this.product, product)) { + this.product = product; + notifyDataChanged(); + } + } + + public String getBrand() { + return brand; + } + + public void setBrand(String brand) { + if (!TextUtils.equals(this.brand, brand)) { + this.brand = brand; + notifyDataChanged(); + } + } + + public String getCpu() { + return cpu; + } + + public void setCpu(String cpu) { + if (!TextUtils.equals(this.cpu, cpu)) { + this.cpu = cpu; + notifyDataChanged(); + } + } + + public String getDevice() { + return device; + } + + public void setDevice(String device) { + if (!TextUtils.equals(this.device, device)) { + this.device = device; + notifyDataChanged(); + } + } + + public String getCarrier() { + return carrier; + } + + public void setCarrier(String carrier) { + if (!TextUtils.equals(this.carrier, carrier)) { + this.carrier = carrier; + notifyDataChanged(); + } + } + + public String getCurrentCarrier() { + return currentCarrier; + } + + public void setCurrentCarrier(String currentCarrier) { + if (!TextUtils.equals(this.currentCarrier, currentCarrier)) { + this.currentCarrier = currentCarrier; + notifyDataChanged(); + } + } + + public String getNetworkType() { + return networkType; + } + + public void setNetworkType(String networkType) { + if (!TextUtils.equals(this.networkType, networkType)) { + this.networkType = networkType; + notifyDataChanged(); + } + } + + public String getBuildType() { + return buildType; + } + + public void setBuildType(String buildType) { + if (!TextUtils.equals(this.buildType, buildType)) { + this.buildType = buildType; + notifyDataChanged(); + } + } + + public String getBuildId() { + return buildId; + } + + public void setBuildId(String buildId) { + if (!TextUtils.equals(this.buildId, buildId)) { + this.buildId = buildId; + notifyDataChanged(); + } + } + + public String getBootloaderVersion() { + return bootloaderVersion; + } + + public void setBootloaderVersion(String bootloaderVersion) { + if (!TextUtils.equals(this.bootloaderVersion, bootloaderVersion)) { + this.bootloaderVersion = bootloaderVersion; + notifyDataChanged(); + } + } + + public String getRadioVersion() { + return radioVersion; + } + + public void setRadioVersion(String radioVersion) { + if (!TextUtils.equals(this.radioVersion, radioVersion)) { + this.radioVersion = radioVersion; + notifyDataChanged(); + } + } + + public CustomData getCustomData() { + return customData; + } + + public void setCustomData(CustomData customData) { + this.customData = customData; + this.customData.setDataChangedListener(this); + notifyDataChanged(); + } + + public String getLocaleCountryCode() { + return localeCountryCode; + } + + public void setLocaleCountryCode(String localeCountryCode) { + if (!TextUtils.equals(this.localeCountryCode, localeCountryCode)) { + this.localeCountryCode = localeCountryCode; + notifyDataChanged(); + } + } + + public String getLocaleLanguageCode() { + return localeLanguageCode; + } + + public void setLocaleLanguageCode(String localeLanguageCode) { + if (!TextUtils.equals(this.localeLanguageCode, localeLanguageCode)) { + this.localeLanguageCode = localeLanguageCode; + notifyDataChanged(); + } + } + + public String getLocaleRaw() { + return localeRaw; + } + + public void setLocaleRaw(String localeRaw) { + if (!TextUtils.equals(this.localeRaw, localeRaw)) { + this.localeRaw = localeRaw; + notifyDataChanged(); + } + } + + public String getUtcOffset() { + return utcOffset; + } + + public void setUtcOffset(String utcOffset) { + if (!TextUtils.equals(this.utcOffset, utcOffset)) { + this.utcOffset = utcOffset; + notifyDataChanged(); + } + } + + public IntegrationConfig getIntegrationConfig() { + return integrationConfig; + } + + public void setIntegrationConfig(IntegrationConfig integrationConfig) { + if (integrationConfig == null) { + throw new IllegalArgumentException("Integration config is null"); + } + this.integrationConfig = integrationConfig; + this.integrationConfig.setDataChangedListener(this); + notifyDataChanged(); + } + + //endregion + +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DeviceManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DeviceManager.java index 70bdf4197..5d40e5390 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DeviceManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DeviceManager.java @@ -7,122 +7,30 @@ package com.apptentive.android.sdk.storage; import android.content.Context; -import android.content.SharedPreferences; import android.os.Build; import android.telephony.TelephonyManager; import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.model.CustomData; -import com.apptentive.android.sdk.model.Device; +import com.apptentive.android.sdk.model.DevicePayload; import com.apptentive.android.sdk.util.Constants; -import com.apptentive.android.sdk.util.JsonDiffer; -import org.json.JSONException; +import com.apptentive.android.sdk.util.Util; import java.util.Locale; import java.util.TimeZone; /** - * A helper class with static methods for getting, storing, retrieving, and diffing information about the current device. - * - * @author Sky Kelsey + * A helper class with static methods for and diffing information about the current device. */ public class DeviceManager { - /** - * If any device setting has changed, return only the changed fields in a new Device object. If a field's value was - * cleared, set that value to null in the Device. The first time this is called, all Device will be returned. - * - * @return A Device containing diff data which, when added to the last sent Device, yields the new Device. - */ - public static Device storeDeviceAndReturnDiff() { - - Device stored = getStoredDevice(); - - Device current = generateNewDevice(); - CustomData customData = loadCustomDeviceData(); - current.setCustomData(customData); - CustomData integrationConfig = loadIntegrationConfig(); - current.setIntegrationConfig(integrationConfig); - - Object diff = JsonDiffer.getDiff(stored, current); - if (diff != null) { - try { - storeDevice(current); - return new Device(diff.toString()); - } catch (JSONException e) { - ApptentiveLog.e("Error casting to Device.", e); - } - } - return null; - } - - /** - * Provided so we can be sure that the device we send during conversation creation is 100% accurate. Since we do not - * queue this device up in the payload queue, it could otherwise be lost. - */ - public static Device storeDeviceAndReturnIt() { - Device current = generateNewDevice(); - CustomData customData = loadCustomDeviceData(); - current.setCustomData(customData); - CustomData integrationConfig = loadIntegrationConfig(); - current.setIntegrationConfig(integrationConfig); - storeDevice(current); - return current; - } - - public static CustomData loadCustomDeviceData() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String deviceDataString = prefs.getString(Constants.PREF_KEY_DEVICE_DATA, null); - try { - return new CustomData(deviceDataString); - } catch (Exception e) { - // Ignore - } - try { - return new CustomData(); - } catch (JSONException e) { - // Ignore - } - return null; // This should never happen. - } - - public static void storeCustomDeviceData(CustomData deviceData) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String deviceDataString = deviceData.toString(); - prefs.edit().putString(Constants.PREF_KEY_DEVICE_DATA, deviceDataString).apply(); - } - - public static CustomData loadIntegrationConfig() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String integrationConfigString = prefs.getString(Constants.PREF_KEY_DEVICE_INTEGRATION_CONFIG, null); - try { - return new CustomData(integrationConfigString); - } catch (Exception e) { - // Ignore - } - try { - return new CustomData(); - } catch (JSONException e) { - // Ignore - } - return null; // This should never happen. - } - - public static void storeIntegrationConfig(CustomData integrationConfig) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String integrationConfigString = integrationConfig.toString(); - prefs.edit().putString(Constants.PREF_KEY_DEVICE_INTEGRATION_CONFIG, integrationConfigString).apply(); - } - - private static Device generateNewDevice() { + public static Device generateNewDevice(Context context) { Device device = new Device(); // First, get all the information we can load from static resources. device.setOsName("Android"); device.setOsVersion(Build.VERSION.RELEASE); device.setOsBuild(Build.VERSION.INCREMENTAL); - device.setOsApiLevel(String.valueOf(Build.VERSION.SDK_INT)); + device.setOsApiLevel(Build.VERSION.SDK_INT); device.setManufacturer(Build.MANUFACTURER); device.setModel(Build.MODEL); device.setBoard(Build.BOARD); @@ -130,7 +38,7 @@ private static Device generateNewDevice() { device.setBrand(Build.BRAND); device.setCpu(Build.CPU_ABI); device.setDevice(Build.DEVICE); - device.setUuid(ApptentiveInternal.getInstance().getAndroidId()); + device.setUuid(Util.getAndroidId(context)); device.setBuildType(Build.TYPE); device.setBuildId(Build.ID); @@ -156,22 +64,144 @@ private static Device generateNewDevice() { return device; } - public static Device getStoredDevice() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String deviceString = prefs.getString(Constants.PREF_KEY_DEVICE, null); - try { - return new Device(deviceString); - } catch (Exception e) { - // Ignore + public static DevicePayload getDiffPayload(com.apptentive.android.sdk.storage.Device oldDevice, com.apptentive.android.sdk.storage.Device newDevice) { + if (newDevice == null) { + return null; + } + + DevicePayload ret = new DevicePayload(); + boolean changed = false; + + if (oldDevice == null || !equal(oldDevice.getUuid(), newDevice.getUuid())) { + ret.setUuid(newDevice.getUuid()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getOsName(), newDevice.getOsName())) { + ret.setOsName(newDevice.getOsName()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getOsVersion(), newDevice.getOsVersion())) { + ret.setOsVersion(newDevice.getOsVersion()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getOsBuild(), newDevice.getOsBuild())) { + ret.setOsBuild(newDevice.getOsBuild()); + changed = true; + } + + if (oldDevice == null || oldDevice.getOsApiLevel() != newDevice.getOsApiLevel()) { + ret.setOsApiLevel(String.valueOf(newDevice.getOsApiLevel())); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getManufacturer(), newDevice.getManufacturer())) { + ret.setManufacturer(newDevice.getManufacturer()); + changed = true; } - return null; - } - private static void storeDevice(Device device) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_DEVICE, device.toString()).apply(); + if (oldDevice == null || !equal(oldDevice.getModel(), newDevice.getModel())) { + ret.setModel(newDevice.getModel()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getBoard(), newDevice.getBoard())) { + ret.setBoard(newDevice.getBoard()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getProduct(), newDevice.getProduct())) { + ret.setProduct(newDevice.getProduct()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getBrand(), newDevice.getBrand())) { + ret.setBrand(newDevice.getBrand()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getCpu(), newDevice.getCpu())) { + ret.setCpu(newDevice.getCpu()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getDevice(), newDevice.getDevice())) { + ret.setDevice(newDevice.getDevice()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getCarrier(), newDevice.getCarrier())) { + ret.setCarrier(newDevice.getCarrier()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getCurrentCarrier(), newDevice.getCurrentCarrier())) { + ret.setCurrentCarrier(newDevice.getCurrentCarrier()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getNetworkType(), newDevice.getNetworkType())) { + ret.setNetworkType(newDevice.getNetworkType()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getBuildType(), newDevice.getBuildType())) { + ret.setBuildType(newDevice.getBuildType()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getBuildId(), newDevice.getBuildId())) { + ret.setBuildId(newDevice.getBuildId()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getBootloaderVersion(), newDevice.getBootloaderVersion())) { + ret.setBootloaderVersion(newDevice.getBootloaderVersion()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getRadioVersion(), newDevice.getRadioVersion())) { + ret.setRadioVersion(newDevice.getRadioVersion()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getCustomData(), newDevice.getCustomData())) { + CustomData customData = newDevice.getCustomData(); + ret.setCustomData(customData != null ? customData.toJson() : null); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getLocaleCountryCode(), newDevice.getLocaleCountryCode())) { + ret.setLocaleCountryCode(newDevice.getLocaleCountryCode()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getLocaleLanguageCode(), newDevice.getLocaleLanguageCode())) { + ret.setLocaleLanguageCode(newDevice.getLocaleLanguageCode()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getLocaleRaw(), newDevice.getLocaleRaw())) { + ret.setLocaleRaw(newDevice.getLocaleRaw()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getUtcOffset(), newDevice.getUtcOffset())) { + ret.setUtcOffset(newDevice.getUtcOffset()); + changed = true; + } + + if (oldDevice == null || !equal(oldDevice.getIntegrationConfig(), newDevice.getIntegrationConfig())) { + IntegrationConfig integrationConfig = newDevice.getIntegrationConfig(); + ret.setIntegrationConfig(integrationConfig != null ? integrationConfig.toJson() : null); + changed = true; + } + return changed ? ret : null; } - public static void onSentDeviceInfo() { + private static boolean equal(Object a, Object b) { + return a == null && b == null || a != null && b != null && a.equals(b); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/EncryptedFileSerializer.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EncryptedFileSerializer.java new file mode 100644 index 000000000..a2dc89274 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EncryptedFileSerializer.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.encryption.Encryptor; +import com.apptentive.android.sdk.util.Util; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +public class EncryptedFileSerializer extends FileSerializer { + private final String encryptionKey; + + public EncryptedFileSerializer(File file, String encryptionKey) { + super(file); + + if (encryptionKey == null) { + throw new IllegalArgumentException("'encryptionKey' is null"); + } + + this.encryptionKey = encryptionKey; + } + + @Override + protected void serialize(File file, Object object) throws SerializerException { + ByteArrayOutputStream bos = null; + ObjectOutputStream oos = null; + try { + bos = new ByteArrayOutputStream(); + oos = new ObjectOutputStream(bos); + oos.writeObject(object); + final byte[] unencryptedBytes = bos.toByteArray(); + Encryptor encryptor = new Encryptor(encryptionKey); + final byte[] encryptedBytes = encryptor.encrypt(unencryptedBytes); + Util.writeBytes(file, encryptedBytes); + } catch (Exception e) { + throw new SerializerException(e); + } finally { + Util.ensureClosed(bos); + Util.ensureClosed(oos); + } + } + + @Override + protected Object deserialize(File file) throws SerializerException { + try { + final byte[] encryptedBytes = Util.readBytes(file); + Encryptor encryptor = new Encryptor(encryptionKey); + final byte[] unencryptedBytes = encryptor.decrypt(encryptedBytes); + + ByteArrayInputStream bis = null; + ObjectInputStream ois = null; + try { + bis = new ByteArrayInputStream(unencryptedBytes); + ois = new ObjectInputStream(bis); + return ois.readObject(); + } catch (Exception e) { + throw new SerializerException(e); + } finally { + Util.ensureClosed(bis); + Util.ensureClosed(ois); + } + } catch (Exception e) { + throw new SerializerException(e); + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/EventData.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EventData.java new file mode 100644 index 000000000..77d12a9f0 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EventData.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import java.util.HashMap; +import java.util.Map; + +/** + * Stores a record of when events and interactions were triggered, as well as the number of times per versionName or versionCode. + */ +public class EventData implements Saveable { + + private static final long serialVersionUID = 1L; + + private Map events; // we need a synchronized access to the map to avoid concurrent modification exceptions + private Map interactions; // we need a synchronized access to the map to avoid concurrent modification exceptions + + public EventData() { + events = new HashMap(); + interactions = new HashMap(); + } + + //region Listeners + private transient DataChangedListener listener; + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + //endregion + + + // FIXME: Find all usage of this and ensure they use the same timestamp for saving events and runnign interaction queries. + public synchronized void storeEventForCurrentAppVersion(double timestamp, int versionCode, String versionName, String eventLabel) { + EventRecord eventRecord = events.get(eventLabel); + if (eventRecord == null) { + eventRecord = new EventRecord(); + events.put(eventLabel, eventRecord); + } + eventRecord.update(timestamp, versionName, versionCode); + notifyDataChanged(); + } + + // FIXME: Find all usage of this and ensure they use the same timestamp for saving events and runnign interaction queries. + public synchronized void storeInteractionForCurrentAppVersion(double timestamp, int versionCode, String versionName, String interactionId) { + EventRecord eventRecord = interactions.get(interactionId); + if (eventRecord == null) { + eventRecord = new EventRecord(); + interactions.put(interactionId, eventRecord); + } + eventRecord.update(timestamp, versionName, versionCode); + notifyDataChanged(); + } + + public synchronized Long getEventCountTotal(String eventLabel) { + EventRecord eventRecord = events.get(eventLabel); + if (eventRecord == null) { + return 0L; + } + return eventRecord.getTotal(); + } + + public synchronized Long getInteractionCountTotal(String interactionId) { + EventRecord eventRecord = interactions.get(interactionId); + if (eventRecord != null) { + return eventRecord.getTotal(); + } + return 0L; + } + + public synchronized Double getTimeOfLastEventInvocation(String eventLabel) { + EventRecord eventRecord = events.get(eventLabel); + if (eventRecord != null) { + return eventRecord.getLast(); + } + return null; + } + + public synchronized Double getTimeOfLastInteractionInvocation(String interactionId) { + EventRecord eventRecord = interactions.get(interactionId); + if (eventRecord != null) { + return eventRecord.getLast(); + } + return null; + } + + public synchronized Long getEventCountForVersionCode(String eventLabel, Integer versionCode) { + EventRecord eventRecord = events.get(eventLabel); + if (eventRecord != null) { + return eventRecord.getCountForVersionCode(versionCode); + } + return 0L; + } + + public synchronized Long getInteractionCountForVersionCode(String interactionId, Integer versionCode) { + EventRecord eventRecord = interactions.get(interactionId); + if (eventRecord != null) { + return eventRecord.getCountForVersionCode(versionCode); + } + return 0L; + } + + public synchronized Long getEventCountForVersionName(String eventLabel, String versionName) { + EventRecord eventRecord = events.get(eventLabel); + if (eventRecord != null) { + return eventRecord.getCountForVersionName(versionName); + } + return 0L; + } + + public synchronized Long getInteractionCountForVersionName(String interactionId, String versionName) { + EventRecord eventRecord = interactions.get(interactionId); + if (eventRecord != null) { + return eventRecord.getCountForVersionName(versionName); + } + return 0L; + } + + + public synchronized String toString() { + StringBuilder builder = new StringBuilder(); + builder.append("Events: "); + for (String key : events.keySet()) { + builder.append("\n\t").append(key).append(": ").append(events.get(key).toString()); + } + builder.append("\nInteractions: "); + for (String key : interactions.keySet()) { + builder.append("\n\t").append(key).append(": ").append(interactions.get(key).toString()); + } + return builder.toString(); + } + + //region Getters & Setters + + /** + * Used for migration only. + */ + public synchronized void setEvents(Map events) { + this.events = events; + notifyDataChanged(); + } + + /** + * Used for migration only. + */ + public synchronized void setInteractions(Map interactions) { + this.interactions = interactions; + notifyDataChanged(); + } + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/EventRecord.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EventRecord.java new file mode 100644 index 000000000..542390999 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EventRecord.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; + +/** + * Stores a record of an event occurring. + */ +public class EventRecord implements Serializable { + private double last; + private long total; + private Map versionCodes; + private Map versionNames; + + public EventRecord() { + last = 0D; + total = 0L; + versionCodes = new HashMap(); + versionNames = new HashMap(); + } + + //region Getters & Setters + + public double getLast() { + return last; + } + + public long getTotal() { + return total; + } + + //endregion + + /** + * Initializes an event record or updates it with a subsequent event. + * @param timestamp The timestamp in seconds at which and Event occurred. + * @param versionName The Android versionName of the app when the event occurred. + * @param versionCode The Android versionCode of the app when the event occurred. + */ + public void update(double timestamp, String versionName, Integer versionCode) { + last = timestamp; + total++; + Long countForVersionName = versionNames.get(versionName); + if (countForVersionName == null) { + countForVersionName = 0L; + } + Long countForVersionCode = versionCodes.get(versionCode); + if (countForVersionCode == null) { + countForVersionCode = 0L; + } + versionNames.put(versionName, countForVersionName + 1); + versionCodes.put(versionCode, countForVersionCode + 1); + } + + public Long getCountForVersionName(String versionName) { + Long count = versionNames.get(versionName); + if (count != null) { + return count; + } + return 0L; + } + + public Long getCountForVersionCode(Integer versionCode) { + Long count = versionCodes.get(versionCode); + if (count != null) { + return count; + } + return 0L; + } + + /** + * Only access directly for migration. + */ + public void setLast(double last) { + this.last = last; + } + + /** + * Only access directly for migration. + */ + public void setTotal(long total) { + this.total = total; + } + + /** + * Only access directly for migration. + */ + public void setVersionCodes(Map versionCodes) { + this.versionCodes = versionCodes; + } + + /** + * Only access directly for migration. + */ + public void setVersionNames(Map versionNames) { + this.versionNames = versionNames; + } + + @Override + public String toString() { + return "EventRecord{" + + "last=" + last + + ", total=" + total + + ", versionNames=" + versionNames + + ", versionCodes=" + versionCodes + + '}'; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/FileSerializer.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/FileSerializer.java new file mode 100644 index 000000000..767aa5029 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/FileSerializer.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.util.Util; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +public class FileSerializer implements Serializer { + + private final File file; + + public FileSerializer(File file) { + if (file == null) { + throw new IllegalArgumentException("'file' is null"); + } + this.file = file; + } + + @Override + public void serialize(Object object) throws SerializerException { + file.getParentFile().mkdirs(); + serialize(file, object); + } + + @Override + public Object deserialize() throws SerializerException { + return deserialize(file); + } + + protected void serialize(File file, Object object) throws SerializerException { + ByteArrayOutputStream bos; + ObjectOutputStream oos = null; + FileOutputStream fos = null; + try { + bos = new ByteArrayOutputStream(); + oos = new ObjectOutputStream(bos); + oos.writeObject(object); + fos = new FileOutputStream(file); + fos.write(bos.toByteArray()); + } catch (Exception e) { + throw new SerializerException(e); + } finally { + Util.ensureClosed(fos); + Util.ensureClosed(oos); + } + } + + protected Object deserialize(File file) throws SerializerException { + FileInputStream fis = null; + ObjectInputStream ois = null; + try { + fis = new FileInputStream(file); + ois = new ObjectInputStream(fis); + return ois.readObject(); + } catch (Exception e) { + throw new SerializerException(e); + } finally { + Util.ensureClosed(fis); + Util.ensureClosed(ois); + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/IntegrationConfig.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/IntegrationConfig.java new file mode 100644 index 000000000..2bc42ec3d --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/IntegrationConfig.java @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import org.json.JSONException; + + +public class IntegrationConfig implements Saveable { + + private static final long serialVersionUID = 1L; + + private static final String INTEGRATION_APPTENTIVE_PUSH = "apptentive_push"; + private static final String INTEGRATION_AWS_SNS = "aws_sns"; + private static final String INTEGRATION_URBAN_AIRSHIP = "urban_airship"; + private static final String INTEGRATION_PARSE = "parse"; + + private IntegrationConfigItem apptentive; + private IntegrationConfigItem amazonAwsSns; + private IntegrationConfigItem urbanAirship; + private IntegrationConfigItem parse; + + private transient DataChangedListener listener; + + + //region Listeners + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + //endregion + + //region Getters & Setters + public IntegrationConfigItem getApptentive() { + return apptentive; + } + + public void setApptentive(IntegrationConfigItem apptentive) { + this.apptentive = apptentive; + notifyDataChanged(); + } + + public IntegrationConfigItem getAmazonAwsSns() { + return amazonAwsSns; + } + + public void setAmazonAwsSns(IntegrationConfigItem amazonAwsSns) { + this.amazonAwsSns = amazonAwsSns; + notifyDataChanged(); + } + + public IntegrationConfigItem getUrbanAirship() { + return urbanAirship; + } + + public void setUrbanAirship(IntegrationConfigItem urbanAirship) { + this.urbanAirship = urbanAirship; + notifyDataChanged(); + } + + public IntegrationConfigItem getParse() { + return parse; + } + + public void setParse(IntegrationConfigItem parse) { + this.parse = parse; + notifyDataChanged(); + } + //endregion + + public com.apptentive.android.sdk.model.CustomData toJson() { + try { + com.apptentive.android.sdk.model.CustomData ret = new com.apptentive.android.sdk.model.CustomData(); + if (apptentive != null) { + ret.put(INTEGRATION_APPTENTIVE_PUSH, apptentive.toJson()); + } + if (amazonAwsSns != null) { + ret.put(INTEGRATION_AWS_SNS, amazonAwsSns.toJson()); + } + if (urbanAirship != null) { + ret.put(INTEGRATION_URBAN_AIRSHIP, urbanAirship.toJson()); + } + if (parse != null) { + ret.put(INTEGRATION_PARSE, parse.toJson()); + } + return ret; + } catch (JSONException e) { + // This can't happen. + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + IntegrationConfig that = (IntegrationConfig) o; + + if (apptentive != null ? !apptentive.equals(that.apptentive) : that.apptentive != null) + return false; + if (amazonAwsSns != null ? !amazonAwsSns.equals(that.amazonAwsSns) : that.amazonAwsSns != null) + return false; + if (urbanAirship != null ? !urbanAirship.equals(that.urbanAirship) : that.urbanAirship != null) + return false; + return parse != null ? parse.equals(that.parse) : that.parse == null; + + } + + @Override + public int hashCode() { + int result = apptentive != null ? apptentive.hashCode() : 0; + result = 31 * result + (amazonAwsSns != null ? amazonAwsSns.hashCode() : 0); + result = 31 * result + (urbanAirship != null ? urbanAirship.hashCode() : 0); + result = 31 * result + (parse != null ? parse.hashCode() : 0); + return result; + } + + // TODO: unit tests + public IntegrationConfig clone() { + IntegrationConfig clone = new IntegrationConfig(); + clone.apptentive = apptentive != null ? apptentive.clone() : null; + clone.amazonAwsSns = amazonAwsSns != null ? amazonAwsSns.clone() : null; + clone.urbanAirship = urbanAirship != null ? urbanAirship.clone() : null; + clone.parse = parse != null ? parse.clone() : null; + clone.listener = listener; + return clone; + } + +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/IntegrationConfigItem.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/IntegrationConfigItem.java new file mode 100644 index 000000000..205d3d2ae --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/IntegrationConfigItem.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.HashMap; +import java.util.Set; + +public class IntegrationConfigItem implements Serializable { + + private static final long serialVersionUID = 1L; + + private static final String KEY_TOKEN = "token"; + + private HashMap contents = new HashMap<>(); + + public IntegrationConfigItem() { + } + + public IntegrationConfigItem(JSONObject old) { + String oldToken = old.optString(KEY_TOKEN, null); + setToken(oldToken); + } + + public void setToken(String token) { + contents.put(KEY_TOKEN, token); + } + + public com.apptentive.android.sdk.model.CustomData toJson() { + try { + com.apptentive.android.sdk.model.CustomData ret = new com.apptentive.android.sdk.model.CustomData(); + Set keys = contents.keySet(); + for (String key : keys) { + ret.put(key, contents.get(key)); + } + return ret; + } catch (JSONException e) { + // This can't happen. + } + return null; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + IntegrationConfigItem that = (IntegrationConfigItem) o; + + return contents != null ? contents.equals(that.contents) : that.contents == null; + + } + + @Override + public int hashCode() { + return contents != null ? contents.hashCode() : 0; + } + + // TODO: unit testing + public IntegrationConfigItem clone() { + IntegrationConfigItem clone = new IntegrationConfigItem(); + clone.contents.putAll(contents); + return clone; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/MessageStore.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/MessageStore.java index 3452acad9..03fecb88c 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/MessageStore.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/MessageStore.java @@ -7,27 +7,28 @@ package com.apptentive.android.sdk.storage; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.ApptentiveMessage; import java.util.List; -import java.util.concurrent.Future; /** * @author Sky Kelsey */ -public interface MessageStore extends PayloadStore { +public interface MessageStore { void addOrUpdateMessages(ApptentiveMessage... apptentiveMessage); void updateMessage(ApptentiveMessage apptentiveMessage); - Future> getAllMessages() throws Exception; + List getAllMessages() throws Exception; - Future getLastReceivedMessageId() throws Exception; + String getLastReceivedMessageId() throws Exception; - Future getUnreadMessageCount() throws Exception; + int getUnreadMessageCount() throws Exception; void deleteAllMessages(); void deleteMessage(String nonce); + + ApptentiveMessage findMessage(String nonce); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadRequestSender.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadRequestSender.java new file mode 100644 index 000000000..9fc1e44cc --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadRequestSender.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.model.PayloadData; +import com.apptentive.android.sdk.network.HttpRequest; + +/** + * Class responsible for creating a {@link HttpRequest} for a given payload + * FIXME: this is a legacy workaround and might be removed soon + */ +public interface PayloadRequestSender { + /** + * Creates and sends an {@link HttpRequest} for a given payload + * + * @param payload to be sent + * @param listener Http-request listener for the payload request + */ + HttpRequest createPayloadSendRequest(PayloadData payload, HttpRequest.Listener listener); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadSendWorker.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadSendWorker.java deleted file mode 100644 index f997d4c4e..000000000 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadSendWorker.java +++ /dev/null @@ -1,229 +0,0 @@ -/* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.sdk.storage; - -import android.os.Handler; -import android.os.Looper; -import android.os.Message; -import android.text.TextUtils; - -import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.comm.ApptentiveClient; -import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; -import com.apptentive.android.sdk.model.*; -import com.apptentive.android.sdk.module.messagecenter.MessageManager; -import com.apptentive.android.sdk.module.messagecenter.model.ApptentiveMessage; -import com.apptentive.android.sdk.module.metric.MetricModule; -import com.apptentive.android.sdk.util.Util; - -import java.util.concurrent.Future; -import java.util.concurrent.atomic.AtomicBoolean; - -/** - * @author Sky Kelsey - */ -public class PayloadSendWorker { - - private static final int NO_TOKEN_SLEEP = 5000; - private static final int NO_CONNECTION_SLEEP_TIME = 5000; - private static final int SERVER_ERROR_SLEEP_TIME = 5000; - - private static final int UI_THREAD_MESSAGE_RETRY_CHECK = 1; - - private PayloadSendRunnable payloadSendRunnable; - private Handler uiHandler; - - private AtomicBoolean appInForeground = new AtomicBoolean(false); - private AtomicBoolean threadRunning = new AtomicBoolean(false); - private AtomicBoolean threadCanRun = new AtomicBoolean(false); - - - public PayloadSendWorker() { - } - - /* expect: true, createNew: true Check if payloadSendRunnable can be run and create a new one if not exist - * expect: true, createNew: false Check if payloadSendRunnable can be run only if one already exists - * expect: false Nullify payloadSendRunnable and cancel pending check as well - */ - public synchronized void checkIfStartSendPayload(boolean expect, boolean createNew) { - if (expect && createNew && payloadSendRunnable == null) { - payloadSendRunnable = new PayloadSendRunnable(); - } else if (!expect) { - uiHandler.removeMessages(UI_THREAD_MESSAGE_RETRY_CHECK); - payloadSendRunnable = null; - uiHandler = null; - } - - if (payloadSendRunnable != null) { - if (uiHandler == null) { - uiHandler = new Handler(Looper.getMainLooper()) { - @Override - public void handleMessage(android.os.Message msg) { - switch (msg.what) { - case UI_THREAD_MESSAGE_RETRY_CHECK: - checkIfStartSendPayload(true, true); - break; - default: - super.handleMessage(msg); - } - } - }; - } else { - uiHandler.removeMessages(UI_THREAD_MESSAGE_RETRY_CHECK); - } - - if (threadCanRun.get() && !threadRunning.get()) { - // Check passed - threadRunning.set(true); - // Start payload send runnable now - ApptentiveInternal.getInstance().runOnWorkerThread(payloadSendRunnable); - } - } - } - - private PayloadStore getPayloadStore() { - return ApptentiveInternal.getInstance().getApptentiveTaskManager(); - } - - private class PayloadSendRunnable implements Runnable { - - public PayloadSendRunnable() { - } - - public void run() { - try { - ApptentiveLog.v("Started %s", toString()); - - while (appInForeground.get()) { - MessageManager mgr = ApptentiveInternal.getInstance().getMessageManager(); - - if (TextUtils.isEmpty(ApptentiveInternal.getInstance().getApptentiveConversationToken())){ - ApptentiveLog.i("No conversation token yet."); - if (mgr != null) { - mgr.pauseSending(MessageManager.SEND_PAUSE_REASON_SERVER); - } - retryLater(NO_TOKEN_SLEEP); - break; - } - if (!Util.isNetworkConnectionPresent()) { - ApptentiveLog.d("Can't send payloads. No network connection."); - if (mgr != null) { - mgr.pauseSending(MessageManager.SEND_PAUSE_REASON_NETWORK); - } - retryLater(NO_CONNECTION_SLEEP_TIME); - break; - } - ApptentiveLog.v("Checking for payloads to send."); - - Payload payload = null; - try { - Future future = ApptentiveInternal.getInstance().getApptentiveTaskManager().getOldestUnsentPayload(); - payload = future.get(); - } catch (Exception e) { - ApptentiveLog.e("Error getting oldest unsent payload in worker thread"); - } - if (payload == null) { - // There is no payload in the db. Terminate the thread - threadCanRun.set(false); - break; - } - ApptentiveLog.d("Got a payload to send: %s:%d", payload.getBaseType(), payload.getDatabaseId()); - - ApptentiveHttpResponse response = null; - - - switch (payload.getBaseType()) { - case message: - if (mgr != null) { - mgr.resumeSending(); - } - response = ApptentiveClient.postMessage((ApptentiveMessage) payload); - if (mgr != null) { - // if message is rejected temporarily, onSentMessage() will pause sending - mgr.onSentMessage((ApptentiveMessage) payload, response); - } - break; - case event: - response = ApptentiveClient.postEvent((Event) payload); - break; - case device: - response = ApptentiveClient.putDevice((Device) payload); - DeviceManager.onSentDeviceInfo(); - break; - case sdk: - response = ApptentiveClient.putSdk((Sdk) payload); - break; - case app_release: - response = ApptentiveClient.putAppRelease((AppRelease) payload); - break; - case person: - response = ApptentiveClient.putPerson((Person) payload); - break; - case survey: - response = ApptentiveClient.postSurvey((SurveyResponse) payload); - break; - default: - ApptentiveLog.e("Didn't send unknown Payload BaseType: " + payload.getBaseType()); - ApptentiveInternal.getInstance().getApptentiveTaskManager().deletePayload(payload); - break; - } - - // Each Payload type is handled by the appropriate handler, but if sent correctly, or failed permanently to send, it should be removed from the queue. - if (response != null) { - if (response.isSuccessful()) { - ApptentiveLog.d("Payload submission successful. Removing from send queue."); - ApptentiveInternal.getInstance().getApptentiveTaskManager().deletePayload(payload); - } else if (response.isRejectedPermanently() || response.isBadPayload()) { - ApptentiveLog.d("Payload rejected. Removing from send queue."); - ApptentiveLog.v("Rejected json:", payload.toString()); - ApptentiveInternal.getInstance().getApptentiveTaskManager().deletePayload(payload); - } else if (response.isRejectedTemporarily()) { - ApptentiveLog.d("Unable to send JSON. Leaving in queue."); - if (response.isException()) { - retryLater(NO_CONNECTION_SLEEP_TIME); - break; - } else { - retryLater(SERVER_ERROR_SLEEP_TIME); - break; - } - } - } - } - } catch (Throwable throwable) { - MetricModule.sendError(throwable, null, null); - } finally - { - ApptentiveLog.v("Stopping PayloadSendThread."); - threadRunning.set(false); - } - } - - private void retryLater(int millis) { - Message msg = uiHandler.obtainMessage(UI_THREAD_MESSAGE_RETRY_CHECK); - uiHandler.removeMessages(UI_THREAD_MESSAGE_RETRY_CHECK); - uiHandler.sendMessageDelayed(msg, millis); - } - } - - public void appWentToForeground() { - appInForeground.set(true); - checkIfStartSendPayload(true, true); - } - - public void appWentToBackground() { - appInForeground.set(false); - checkIfStartSendPayload(true, false); - } - - public void setCanRunPayloadThread(boolean b) { - threadCanRun.set(b); - if (uiHandler == null || !uiHandler.hasMessages(UI_THREAD_MESSAGE_RETRY_CHECK)) { - checkIfStartSendPayload(true, true); - } - } -} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadSender.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadSender.java new file mode 100644 index 000000000..771570d91 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadSender.java @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.model.PayloadData; +import com.apptentive.android.sdk.network.HttpRequest; +import com.apptentive.android.sdk.network.HttpRequestRetryPolicy; +import com.apptentive.android.sdk.notifications.ApptentiveNotificationCenter; +import com.apptentive.android.sdk.util.StringUtils; + +import org.json.JSONObject; + +import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_AUTHENTICATION_FAILED; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_AUTHENTICATION_FAILED_REASON; +import static com.apptentive.android.sdk.ApptentiveNotifications.NOTIFICATION_KEY_CONVERSATION_ID; + +/** + * Class responsible for a serial payload sending (one at a time) + */ +class PayloadSender { + /** + * Object which creates and send Http-request for payloads + */ + private final PayloadRequestSender requestSender; + + /** + * Payload Http-request retry policy + */ + private final HttpRequestRetryPolicy requestRetryPolicy; + + private Listener listener; + + /** + * Indicates whenever the sender is busy sending a payload + */ + private boolean sendingFlag; // this variable is only accessed in a synchronized context + + PayloadSender(PayloadRequestSender requestSender, HttpRequestRetryPolicy retryPolicy) { + if (requestSender == null) { + throw new IllegalArgumentException("Payload request sender is null"); + } + + if (retryPolicy == null) { + throw new IllegalArgumentException("Retry policy is null"); + } + + this.requestSender = requestSender; + this.requestRetryPolicy = retryPolicy; + } + + //region Payloads + + /** + * Sends payload asynchronously. Returns boolean flag immediately indicating if payload send was + * scheduled + * + * @throws IllegalArgumentException is payload is null + */ + synchronized boolean sendPayload(final PayloadData payload) { + if (payload == null) { + throw new IllegalArgumentException("Payload is null"); + } + + // we don't allow concurrent payload sending + if (isSendingPayload()) { + return false; + } + + // we mark the sender as "busy" so no other payloads would be sent until we're done + sendingFlag = true; + + try { + sendPayloadRequest(payload); + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while sending payload: %s", payload); + + // for NullPointerException, the message object would be null, we should handle it separately + // TODO: add a helper class for handling that + String message = e.getMessage(); + if (message == null) { + message = StringUtils.format("%s is thrown", e.getClass().getSimpleName()); + } + + // if an exception was thrown - mark payload as failed + handleFinishSendingPayload(payload, false, message, -1, null); // FIXME: a better approach + } + + return true; + } + + /** + * Creates and sends payload Http-request asynchronously (returns immediately) + * @param payload + */ + private synchronized void sendPayloadRequest(final PayloadData payload) { + ApptentiveLog.v(PAYLOADS, "Sending payload: %s", payload); + + // create request object + final HttpRequest payloadRequest = requestSender.createPayloadSendRequest(payload, new HttpRequest.Listener() { + @Override + public void onFinish(HttpRequest request) { + try { + String json = StringUtils.isNullOrEmpty(request.getResponseData()) ? "{}" : request.getResponseData(); + final JSONObject responseData = new JSONObject(json); + handleFinishSendingPayload(payload, false, null, request.getResponseCode(), responseData); + } catch (Exception e) { + // TODO: Stop assuming the response is JSON. In fact, just send bytes back, and whatever part of the SDK needs it can try to convert it to the desired format. + ApptentiveLog.e(PAYLOADS, e, "Exception while handling payload send response"); + handleFinishSendingPayload(payload, false, null, -1, null); + } + } + + @Override + public void onCancel(HttpRequest request) { + handleFinishSendingPayload(payload, true, null, request.getResponseCode(), null); + } + + @Override + public void onFail(HttpRequest request, String reason) { + if (request.isAuthenticationFailure()) { + ApptentiveNotificationCenter.defaultCenter().postNotification(NOTIFICATION_AUTHENTICATION_FAILED, NOTIFICATION_KEY_CONVERSATION_ID, payload.getConversationId(), NOTIFICATION_KEY_AUTHENTICATION_FAILED_REASON, request.getAuthenticationFailedReason()); + } + handleFinishSendingPayload(payload, false, reason, request.getResponseCode(), null); + } + }); + + // set 'retry' policy + payloadRequest.setRetryPolicy(requestRetryPolicy); + payloadRequest.start(); + } + + //endregion + + //region Listener notification + + /** + * Executed when we're done with the current payload + * @param payload - current payload + * @param cancelled - flag indicating if payload Http-request was cancelled + * @param errorMessage - if not null - payload request failed + * @param responseCode - http-request response code + * @param responseData - http-reqeust response json (or null if failed) + */ + private synchronized void handleFinishSendingPayload(PayloadData payload, boolean cancelled, String errorMessage, int responseCode, JSONObject responseData) { + sendingFlag = false; // mark sender as 'not busy' + + try { + if (listener != null) { + listener.onFinishSending(this, payload, cancelled, errorMessage, responseCode, responseData); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Exception while notifying payload listener"); + } + } + + //endregion + + //region Getters/Setters + + /** + * Returns true if sender is currently busy with a payload + */ + synchronized boolean isSendingPayload() { + return sendingFlag; + } + + public void setListener(Listener listener) { + this.listener = listener; + } + + //endregion + + //region Listener + + public interface Listener { + void onFinishSending(PayloadSender sender, PayloadData payload, boolean cancelled, String errorMessage, int responseCode, JSONObject responseData); + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadStore.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadStore.java index 527762d33..c77343230 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadStore.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadStore.java @@ -2,22 +2,11 @@ import com.apptentive.android.sdk.model.Payload; -import java.util.concurrent.Future; +interface PayloadStore { -/** - * @author Sky Kelsey - */ -public interface PayloadStore { + void addPayload(Payload payloads); - public void addPayload(Payload... payloads); - - public void deletePayload(Payload payload); - - public void deleteAllPayloads(); - - /* Asynchronous call to retrieve the oldest unsent payload from the data storage. - * Calling get() method on the returned Future object will block the caller until the Future has completed, - */ - public Future getOldestUnsentPayload() throws Exception; + void deletePayload(String payloadIdentifier); + void deleteAllPayloads(); } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadTypeSender.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadTypeSender.java new file mode 100644 index 000000000..2af1e5405 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PayloadTypeSender.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; +import com.apptentive.android.sdk.model.JsonPayload; + +interface PayloadTypeSender { + ApptentiveHttpResponse sendPayload(T payload); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/Person.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Person.java new file mode 100644 index 000000000..25b9febe2 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Person.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import android.text.TextUtils; + +public class Person implements Saveable, DataChangedListener { + + private static final long serialVersionUID = 1L; + + private String id; + private String email; + private String name; + private String facebookId; + private String phoneNumber; + private String street; + private String city; + private String zip; + private String country; + private String birthday; + private CustomData customData; + + public Person() { + customData = new CustomData(); + } + + //region Listeners + private transient DataChangedListener listener; + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + customData.setDataChangedListener(this); + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + @Override + public void onDataChanged() { + notifyDataChanged(); + } + //endregion + + //region Getters & Setters + + public String getId() { + return id; + } + + public void setId(String id) { + if (!TextUtils.equals(this.id, id)) { + this.id = id; + notifyDataChanged(); + } + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + if (!TextUtils.equals(this.email, email)) { + this.email = email; + notifyDataChanged(); + } + } + + public String getName() { + return name; + } + + public void setName(String name) { + if (!TextUtils.equals(this.name, name)) { + this.name = name; + notifyDataChanged(); + } + } + + public String getFacebookId() { + return facebookId; + } + + public void setFacebookId(String facebookId) { + if (!TextUtils.equals(this.facebookId, facebookId)) { + this.facebookId = facebookId; + notifyDataChanged(); + } + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + if (!TextUtils.equals(this.phoneNumber, phoneNumber)) { + this.phoneNumber = phoneNumber; + notifyDataChanged(); + } + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + if (!TextUtils.equals(this.street, street)) { + this.street = street; + notifyDataChanged(); + } + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + if (!TextUtils.equals(this.city, city)) { + this.city = city; + notifyDataChanged(); + } + } + + public String getZip() { + return zip; + } + + public void setZip(String zip) { + if (!TextUtils.equals(this.zip, zip)) { + this.zip = zip; + notifyDataChanged(); + } + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + if (!TextUtils.equals(this.country, country)) { + this.country = country; + notifyDataChanged(); + } + } + + public String getBirthday() { + return birthday; + } + + public void setBirthday(String birthday) { + if (!TextUtils.equals(this.birthday, birthday)) { + this.birthday = birthday; + notifyDataChanged(); + } + } + + public CustomData getCustomData() { + return customData; + } + + public void setCustomData(CustomData customData) { + this.customData = customData; + this.customData.setDataChangedListener(this); + notifyDataChanged(); + } + + //endregion + + //region Clone + + public Person clone() { + Person person = new Person(); + person.id = id; + person.email = email; + person.name = name; + person.facebookId = facebookId; + person.phoneNumber = phoneNumber; + person.street = street; + person.city = city; + person.zip = zip; + person.country = country; + person.birthday = birthday; + if (customData != null) { + person.customData.putAll(customData); + } + person.listener = listener; + return person; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PersonManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PersonManager.java index d6d926c87..127f85140 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/PersonManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/PersonManager.java @@ -6,127 +6,78 @@ package com.apptentive.android.sdk.storage; -import android.content.SharedPreferences; +import com.apptentive.android.sdk.model.PersonPayload; -import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.model.CustomData; -import com.apptentive.android.sdk.model.Person; -import com.apptentive.android.sdk.util.Constants; -import com.apptentive.android.sdk.util.JsonDiffer; - -import org.json.JSONException; - -/** - * @author Sky Kelsey - */ public class PersonManager { - public static Person storePersonAndReturnDiff() { - Person stored = getStoredPerson(); - - Person current = generateCurrentPerson(); - CustomData customData = loadCustomPersonData(); - current.setCustomData(customData); - - String email = loadPersonEmail(); - current.setEmail(email); - - String name = loadPersonName(); - current.setName(name); - - Object diff = JsonDiffer.getDiff(stored, current); - if (diff != null) { - try { - storePerson(current); - return new Person(diff.toString()); - } catch (JSONException e) { - ApptentiveLog.e("Error casting to Person.", e); - } + public static PersonPayload getDiffPayload(Person oldPerson, Person newPerson) { + if (newPerson == null) { + return null; } - return null; - } + PersonPayload ret = new PersonPayload(); + boolean changed = false; - /** - * Provided so we can be sure that the person we send during conversation creation is 100% accurate. Since we do not - * queue this person up in the payload queue, it could otherwise be lost. - */ - public static Person storePersonAndReturnIt() { - Person current = generateCurrentPerson(); - - CustomData customData = loadCustomPersonData(); - current.setCustomData(customData); - - String email = loadPersonEmail(); - current.setEmail(email); - - String name = loadPersonName(); - current.setName(name); + if (oldPerson == null || !equal(oldPerson.getId(), newPerson.getId())) { + ret.setId(newPerson.getId()); + changed = true; + } - storePerson(current); - return current; - } + if (oldPerson == null || !equal(oldPerson.getEmail(), newPerson.getEmail())) { + ret.setEmail(newPerson.getEmail()); + changed = true; + } - public static CustomData loadCustomPersonData() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String personDataString = prefs.getString(Constants.PREF_KEY_PERSON_DATA, null); - try { - return new CustomData(personDataString); - } catch (Exception e) { - // Ignore + if (oldPerson == null || !equal(oldPerson.getName(), newPerson.getName())) { + ret.setName(newPerson.getName()); + changed = true; } - try { - return new CustomData(); - } catch (JSONException e) { - // Ignore + + if (oldPerson == null || !equal(oldPerson.getFacebookId(), newPerson.getFacebookId())) { + ret.setFacebookId(newPerson.getFacebookId()); + changed = true; } - return null; - } - public static void storeCustomPersonData(CustomData deviceData) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String personDataString = deviceData.toString(); - prefs.edit().putString(Constants.PREF_KEY_PERSON_DATA, personDataString).apply(); - } + if (oldPerson == null || !equal(oldPerson.getPhoneNumber(), newPerson.getPhoneNumber())) { + ret.setPhoneNumber(newPerson.getPhoneNumber()); + changed = true; + } - private static Person generateCurrentPerson() { - return new Person(); - } + if (oldPerson == null || !equal(oldPerson.getStreet(), newPerson.getStreet())) { + ret.setStreet(newPerson.getStreet()); + changed = true; + } - public static String loadPersonEmail() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - return prefs.getString(Constants.PREF_KEY_PERSON_EMAIL, null); - } + if (oldPerson == null || !equal(oldPerson.getCity(), newPerson.getCity())) { + ret.setCity(newPerson.getCity()); + changed = true; + } - public static void storePersonEmail(String email) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_PERSON_EMAIL, email).apply(); - } + if (oldPerson == null || !equal(oldPerson.getZip(), newPerson.getZip())) { + ret.setZip(newPerson.getZip()); + changed = true; + } - public static String loadPersonName() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - return prefs.getString(Constants.PREF_KEY_PERSON_NAME, null); - } + if (oldPerson == null || !equal(oldPerson.getCountry(), newPerson.getCountry())) { + ret.setCountry(newPerson.getCountry()); + changed = true; + } - public static void storePersonName(String name) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_PERSON_NAME, name).apply(); - } + if (oldPerson == null || !equal(oldPerson.getBirthday(), newPerson.getBirthday())) { + ret.setBirthday(newPerson.getBirthday()); + changed = true; + } - public static Person getStoredPerson() { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - String PersonString = prefs.getString(Constants.PREF_KEY_PERSON, null); - try { - return new Person(PersonString); - } catch (Exception e) { - // Ignore + if (oldPerson == null || !equal(oldPerson.getCustomData(), newPerson.getCustomData())) { + CustomData customData = newPerson.getCustomData(); + ret.setCustomData(customData != null ? customData.toJson() : null); + changed = true; } - return null; + + return changed ? ret : null; } - private static void storePerson(Person Person) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_PERSON, Person.toString()).apply(); + private static boolean equal(Object a, Object b) { + return a == null && b == null || a != null && b != null && a.equals(b); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/Saveable.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Saveable.java new file mode 100644 index 000000000..07b076e43 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Saveable.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import java.io.Serializable; + + +public interface Saveable extends Serializable { + void setDataChangedListener(DataChangedListener listener); + void notifyDataChanged(); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/Sdk.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Sdk.java new file mode 100644 index 000000000..d9ae5a27f --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Sdk.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import java.io.Serializable; + +public class Sdk implements Serializable { + private String version; + private String programmingLanguage; + private String authorName; + private String authorEmail; + private String platform; + private String distribution; + private String distributionVersion; + + //region Getters & Setters + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public String getProgrammingLanguage() { + return programmingLanguage; + } + + public void setProgrammingLanguage(String programmingLanguage) { + this.programmingLanguage = programmingLanguage; + } + + public String getAuthorName() { + return authorName; + } + + public void setAuthorName(String authorName) { + this.authorName = authorName; + } + + public String getAuthorEmail() { + return authorEmail; + } + + public void setAuthorEmail(String authorEmail) { + this.authorEmail = authorEmail; + } + + public String getPlatform() { + return platform; + } + + public void setPlatform(String platform) { + this.platform = platform; + } + + public String getDistribution() { + return distribution; + } + + public void setDistribution(String distribution) { + this.distribution = distribution; + } + + public String getDistributionVersion() { + return distributionVersion; + } + + public void setDistributionVersion(String distributionVersion) { + this.distributionVersion = distributionVersion; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/SdkManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/SdkManager.java index b8e7e5ee5..91ff9fa05 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/SdkManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/SdkManager.java @@ -6,21 +6,13 @@ package com.apptentive.android.sdk.storage; -import android.content.SharedPreferences; - import com.apptentive.android.sdk.ApptentiveInternal; -import com.apptentive.android.sdk.model.Sdk; +import com.apptentive.android.sdk.model.SdkPayload; import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; public class SdkManager { - public static Sdk storeSdkAndReturnIt() { - Sdk current = generateCurrentSdk(); - storeSdk(current); - return current; - } - public static Sdk generateCurrentSdk() { Sdk sdk = new Sdk(); @@ -40,8 +32,19 @@ public static Sdk generateCurrentSdk() { return sdk; } - public static void storeSdk(Sdk sdk) { - SharedPreferences prefs = ApptentiveInternal.getInstance().getSharedPrefs(); - prefs.edit().putString(Constants.PREF_KEY_SDK, sdk.toString()).apply(); + public static SdkPayload getPayload(Sdk sdk) { + SdkPayload ret = new SdkPayload(); + if (sdk == null) { + return ret; + } + + ret.setAuthorEmail(sdk.getAuthorEmail()); + ret.setAuthorName(sdk.getAuthorName()); + ret.setDistribution(sdk.getDistribution()); + ret.setDistributionVersion(sdk.getDistributionVersion()); + ret.setPlatform(sdk.getPlatform()); + ret.setProgrammingLanguage(sdk.getProgrammingLanguage()); + ret.setVersion(sdk.getVersion()); + return ret; } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/Serializer.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Serializer.java new file mode 100644 index 000000000..4963b06dd --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/Serializer.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +public interface Serializer { + + void serialize(Object object) throws SerializerException; + + Object deserialize() throws SerializerException; +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/SerializerException.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/SerializerException.java new file mode 100644 index 000000000..2d22b2f08 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/SerializerException.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +public class SerializerException extends Exception { + public SerializerException(Throwable cause) { + super(cause); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistory.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistory.java new file mode 100644 index 000000000..30e1a9452 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistory.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.Apptentive; +import com.apptentive.android.sdk.ApptentiveInternal; +import com.apptentive.android.sdk.util.Util; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class VersionHistory implements Saveable { + + private static final long serialVersionUID = 1L; + + /** + * An ordered list of version history. Older versions are first, new versions are added to the end. + */ + private List versionHistoryItems; + + public VersionHistory() { + versionHistoryItems = new ArrayList<>(); + } + + //region Listeners + private transient DataChangedListener listener; + + @Override + public void setDataChangedListener(DataChangedListener listener) { + this.listener = listener; + } + + @Override + public void notifyDataChanged() { + if (listener != null) { + listener.onDataChanged(); + } + } + + //endregion + + public void updateVersionHistory(double timestamp, Integer newVersionCode, String newVersionName) { + boolean exists = false; + for (VersionHistoryItem item : versionHistoryItems) { + if (item.getVersionCode() == newVersionCode && item.getVersionName().equals(newVersionName)) { + exists = true; + break; + } + } + if (!exists) { + VersionHistoryItem newVersionHistoryItem = new VersionHistoryItem(timestamp, newVersionCode, newVersionName); + versionHistoryItems.add(newVersionHistoryItem); + notifyDataChanged(); + } + } + + /** + * Returns the timestamp at the first install of this app that Apptentive was aware of. + */ + public Apptentive.DateTime getTimeAtInstallTotal() { + // Simply return the first item's timestamp, if there is one. + if (versionHistoryItems.size() > 0) { + return new Apptentive.DateTime(versionHistoryItems.get(0).getTimestamp()); + } + return new Apptentive.DateTime(Util.currentTimeSeconds()); + } + + /** + * Returns the timestamp at the first install of the current versionCode of this app that Apptentive was aware of. + */ + public Apptentive.DateTime getTimeAtInstallForCurrentVersionCode() { + for (VersionHistoryItem item : versionHistoryItems) { + if (item.getVersionCode() == Util.getAppVersionCode(ApptentiveInternal.getInstance().getApplicationContext())) { + return new Apptentive.DateTime(item.getTimestamp()); + } + } + return new Apptentive.DateTime(Util.currentTimeSeconds()); + } + + /** + * Returns the timestamp at the first install of the current versionName of this app that Apptentive was aware of. + */ + public Apptentive.DateTime getTimeAtInstallForCurrentVersionName() { + for (VersionHistoryItem item : versionHistoryItems) { + Apptentive.Version entryVersionName = new Apptentive.Version(); + Apptentive.Version currentVersionName = new Apptentive.Version(); + entryVersionName.setVersion(item.getVersionName()); + currentVersionName.setVersion(Util.getAppVersionName(ApptentiveInternal.getInstance().getApplicationContext())); + if (entryVersionName.equals(currentVersionName)) { + return new Apptentive.DateTime(item.getTimestamp()); + } + } + return new Apptentive.DateTime(Util.currentTimeSeconds()); + } + + /** + * Returns true if the current versionCode is not the first version or build that we have seen. Basically, it just + * looks for two or more versionCodes. + * + * @return True if this is not the first versionCode of the app we've seen. + */ + public boolean isUpdateForVersionCode() { + Set uniques = new HashSet(); + for (VersionHistoryItem item : versionHistoryItems) { + uniques.add(item.getVersionCode()); + } + return uniques.size() > 1; + } + + /** + * Returns true if the current versionName is not the first version or build that we have seen. Basically, it just + * looks for two or more versionNames. + * + * @return True if this is not the first versionName of the app we've seen. + */ + public boolean isUpdateForVersionName() { + Set uniques = new HashSet(); + for (VersionHistoryItem item : versionHistoryItems) { + uniques.add(item.getVersionName()); + } + return uniques.size() > 1; + } + + public VersionHistoryItem getLastVersionSeen() { + if (!versionHistoryItems.isEmpty()) { + return versionHistoryItems.get(versionHistoryItems.size() - 1); + } + return null; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryItem.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryItem.java new file mode 100644 index 000000000..79f733197 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/VersionHistoryItem.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import java.io.Serializable; + +public class VersionHistoryItem implements Serializable { + + private double timestamp; + private int versionCode; + private String versionName; + + public VersionHistoryItem(double timestamp, int versionCode, String versionName) { + this.timestamp = timestamp; + this.versionCode = versionCode; + this.versionName = versionName; + } + + //region Getters & Setters + + public int getVersionCode() { + return versionCode; + } + + public void setVersionCode(int versionCode) { + this.versionCode = versionCode; + } + + public String getVersionName() { + return versionName; + } + + public void setVersionName(String versionName) { + this.versionName = versionName; + } + + public double getTimestamp() { + return timestamp; + } + + public void setTimestamp(double timestamp) { + this.timestamp = timestamp; + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/legacy/LegacyPayloadFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/legacy/LegacyPayloadFactory.java new file mode 100644 index 000000000..34b623932 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/legacy/LegacyPayloadFactory.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage.legacy; + +import com.apptentive.android.sdk.model.AppReleasePayload; +import com.apptentive.android.sdk.model.DevicePayload; +import com.apptentive.android.sdk.model.EventPayload; +import com.apptentive.android.sdk.model.JsonPayload; +import com.apptentive.android.sdk.model.Payload; +import com.apptentive.android.sdk.model.PayloadType; +import com.apptentive.android.sdk.model.PersonPayload; +import com.apptentive.android.sdk.model.SdkPayload; +import com.apptentive.android.sdk.model.SurveyResponsePayload; +import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; + +import org.json.JSONException; + +/** + * We only keep this class for legacy database migration purposes + */ +public final class LegacyPayloadFactory { + public static JsonPayload createPayload(PayloadType type, String json) throws JSONException { + switch (type) { + case message: + return MessageFactory.fromJson(json); + case event: + return new EventPayload(json); + case device: + return new DevicePayload(json); + case sdk: + //return new SdkPayload(json); + // TODO: FIXME + return null; + case app_release: + //return new AppReleasePayload(json); + return null; + case person: + return new PersonPayload(json); + case survey: + return new SurveyResponsePayload(json); + default: + throw new IllegalArgumentException("Unexpected payload type: " + type); + } + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java index 3c644a8ee..da04702ff 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/Constants.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -8,8 +8,11 @@ public class Constants { - public static final String APPTENTIVE_SDK_VERSION = "3.4.1"; + public static final int API_VERSION = 9; + public static final String APPTENTIVE_SDK_VERSION = "4.0.0"; + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 45000; + public static final int DEFAULT_READ_TIMEOUT_MILLIS = 45000; public static final int REQUEST_CODE_PHOTO_FROM_SYSTEM_PICKER = 10; public static final int REQUEST_CODE_CLOSE_COMPOSING_CONFIRMATION = 20; @@ -18,57 +21,29 @@ public class Constants { public static final String PREF_NAME = "APPTENTIVE"; + // Globals public static final String PREF_KEY_SERVER_URL = "serverUrl"; - // Just in case a customer copies the example text verbatim. - public static final String EXAMPLE_API_KEY_VALUE = "YOUR_APPTENTIVE_API_KEY"; - public static final String PREF_KEY_CONVERSATION_TOKEN = "conversationToken"; - public static final String PREF_KEY_CONVERSATION_ID = "conversationId"; - public static final String PREF_KEY_PERSON_ID = "personId"; - - public static final String PREF_KEY_DEVICE = "device"; - public static final String PREF_KEY_DEVICE_DATA = "deviceData"; - - public static final String PREF_KEY_DEVICE_INTEGRATION_CONFIG = "integrationConfig"; - - public static final String PREF_KEY_SDK = "sdk"; - public static final String PREF_KEY_APP_RELEASE = "app_release"; - public static final String PREF_KEY_PERSON = "person"; - public static final String PREF_KEY_PERSON_DATA = "personData"; - public static final String PREF_KEY_PERSON_EMAIL = "personEmail"; - public static final String PREF_KEY_PERSON_NAME = "personName"; - - public static final String PREF_KEY_LAST_SEEN_SDK_VERSION = "lastSeenSdkVersion"; - - public static final String PREF_KEY_APP_ACTIVITY_STATE_QUEUE = "appActivityStateQueue"; - - public static final String PREF_KEY_MESSAGE_CENTER_FEATURE_USED = "messageCenterFeatureUsed"; - public static final String PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE = "messageCenterPendingComposingMessage"; - public static final String PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS = "messageCenterPendingComposingAttachments"; - public static final String PREF_KEY_MESSAGE_CENTER_SERVER_ERROR_LAST_ATTEMPT = "messageCenterServerErrorLastAttempt"; - - public static final String PREF_KEY_MESSAGE_CENTER_WHO_CARD_DISPLAYED_BEFORE = "messageCenterWhoCardSet"; - public static final String PREF_KEY_APP_CONFIG_PREFIX = "appConfiguration."; public static final String PREF_KEY_APP_CONFIG_JSON = PREF_KEY_APP_CONFIG_PREFIX+"json"; + public static final String PREF_KEY_PUSH_PROVIDER = "pushProvider"; + public static final String PREF_KEY_PUSH_TOKEN = "pushToken"; + + // Just in case a customer copies the example text verbatim. + public static final String EXAMPLE_APPTENTIVE_KEY_VALUE = "YOUR_APPTENTIVE_KEY"; + public static final String EXAMPLE_APPTENTIVE_SIGNATURE_VALUE = "YOUR_APPTENTIVE_SIGNATURE"; - public static final String PREF_KEY_VERSION_HISTORY = "versionHistory"; - public static final String PREF_KEY_VERSION_HISTORY_V2 = "versionHistoryV2"; - // Boolean true if migration from v1 to V2 has occurred. - public static final String PREF_KEY_VERSION_HISTORY_V2_MIGRATED = "versionHistoryV2Migrated"; - public static final String PREF_KEY_PENDING_PUSH_NOTIFICATION = "pendingPushNotification"; // Engagement - public static final String PREF_KEY_INTERACTIONS = "interactions"; - public static final String PREF_KEY_TARGETS = "targets"; - public static final String PREF_KEY_INTERACTIONS_PAYLOAD_CACHE_EXPIRATION = "interactionsCacheExpiration"; - public static final String PREF_KEY_CODE_POINT_STORE = "codePointStore"; // Used to turn off Interaction polling so that contrived payloads can be manually tested. + // FIXME: Migrate into global data. + public static final String PREF_KEY_MESSAGE_CENTER_SERVER_ERROR_LAST_ATTEMPT = "messageCenterServerErrorLastAttempt"; public static final String PREF_KEY_POLL_FOR_INTERACTIONS = "pollForInteractions"; // Config Defaults public static final String CONFIG_DEFAULT_SERVER_URL = "https://api.apptentive.com"; + //region Default Values public static final int CONFIG_DEFAULT_INTERACTION_CACHE_EXPIRATION_DURATION_SECONDS = 28800; // 8 hours public static final int CONFIG_DEFAULT_APP_CONFIG_EXPIRATION_MILLIS = 0; public static final int CONFIG_DEFAULT_APP_CONFIG_EXPIRATION_DURATION_SECONDS = 86400; // 24 hours @@ -77,16 +52,54 @@ public class Constants { public static final boolean CONFIG_DEFAULT_MESSAGE_CENTER_ENABLED = false; public static final boolean CONFIG_DEFAULT_MESSAGE_CENTER_NOTIFICATION_POPUP_ENABLED = false; public static final boolean CONFIG_DEFAULT_HIDE_BRANDING = false; + //endregion - // Manifest keys + // region Android Manifest Keys public static final String MANIFEST_KEY_APPTENTIVE_LOG_LEVEL = "apptentive_log_level"; - public static final String MANIFEST_KEY_APPTENTIVE_API_KEY = "apptentive_api_key"; + public static final String MANIFEST_KEY_APPTENTIVE_KEY = "apptentive_key"; + public static final String MANIFEST_KEY_APPTENTIVE_SIGNATURE = "apptentive_signature"; public static final String MANIFEST_KEY_SDK_DISTRIBUTION = "apptentive_sdk_distribution"; public static final String MANIFEST_KEY_SDK_DISTRIBUTION_VERSION = "apptentive_sdk_distribution_version"; public static final String MANIFEST_KEY_INITIALLY_HIDE_BRANDING = "apptentive_initially_hide_branding"; public static final String MANIFEST_KEY_APPTENTIVE_DEBUG = "apptentive_debug"; + //endregion + + //region Database and File Storage + public static final String CONVERSATIONS_DIR = "apptentive/conversations"; + public static final String PAYLOAD_DATA_DIR = "payloads"; + public static final String PAYLOAD_DATA_FILE_SUFFIX = ".data"; + //endregion - // OLD KEYS USED IN PREVIOUS SDK VERSIONS + // region Keys used to access old data for migration + public static final String PREF_KEY_CONVERSATION_TOKEN = "conversationToken"; + public static final String PREF_KEY_CONVERSATION_ID = "conversationId"; + public static final String PREF_KEY_PERSON_ID = "personId"; + public static final String PREF_KEY_DEVICE = "device"; + public static final String PREF_KEY_SDK = "sdk"; + public static final String PREF_KEY_APP_RELEASE = "app_release"; + public static final String PREF_KEY_PERSON = "person"; + public static final String PREF_KEY_LAST_SEEN_SDK_VERSION = "lastSeenSdkVersion"; + public static final String PREF_KEY_MESSAGE_CENTER_FEATURE_USED = "messageCenterFeatureUsed"; + public static final String PREF_KEY_CODE_POINT_STORE = "codePointStore"; + public static final String PREF_KEY_VERSION_HISTORY = "versionHistory"; + public static final String PREF_KEY_VERSION_HISTORY_V2 = "versionHistoryV2"; + public static final String PREF_KEY_VERSION_HISTORY_V2_MIGRATED = "versionHistoryV2Migrated"; + public static final String PREF_KEY_MESSAGE_CENTER_WHO_CARD_DISPLAYED_BEFORE = "messageCenterWhoCardSet"; + public static final String PREF_KEY_INTERACTIONS_PAYLOAD_CACHE_EXPIRATION = "interactionsCacheExpiration"; + //endregion + + + //region Old keys no longer used + public static final String PREF_KEY_APP_ACTIVITY_STATE_QUEUE = "appActivityStateQueue"; + public static final String PREF_KEY_PERSON_EMAIL = "personEmail"; + public static final String PREF_KEY_PERSON_NAME = "personName"; + public static final String PREF_KEY_DEVICE_DATA = "deviceData"; + public static final String PREF_KEY_DEVICE_INTEGRATION_CONFIG = "integrationConfig"; + public static final String PREF_KEY_PERSON_DATA = "personData"; + public static final String PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_MESSAGE = "messageCenterPendingComposingMessage"; + public static final String PREF_KEY_MESSAGE_CENTER_PENDING_COMPOSING_ATTACHMENTS = "messageCenterPendingComposingAttachments"; + public static final String PREF_KEY_INTERACTIONS = "interactions"; + public static final String PREF_KEY_TARGETS = "targets"; public static final String MANIFEST_KEY_MESSAGE_CENTER_ENABLED = "apptentive_message_center_enabled"; public static final String MANIFEST_KEY_EMAIL_REQUIRED = "apptentive_email_required"; public static final String PREF_KEY_APP_CONFIG_EXPIRATION = PREF_KEY_APP_CONFIG_PREFIX+"cache-expiration"; @@ -110,7 +123,8 @@ public class Constants { public static final String PREF_KEY_AUTO_MESSAGE_SHOWN_MANUAL = "autoMessageShownManual"; public static final String PREF_KEY_MESSAGE_CENTER_SHOULD_SHOW_INTRO_DIALOG = "messageCenterShouldShowIntroDialog"; public static final String MANIFEST_KEY_USE_STAGING_SERVER = "apptentive_use_staging_server"; - + public static final String PREF_KEY_PENDING_PUSH_NOTIFICATION = "pendingPushNotification"; + //region public interface FragmentConfigKeys { @@ -150,7 +164,10 @@ public interface FragmentTypes { "EVDO_B", // 12 "LTE", // 13 "EHRPD", // 14 - "HSPAP" // 15 + "HSPAP", // 15 + "GSM", // 16 + "TD_SCDMA",// 17 + "IWLAN" // 18 }; public static String networkTypeAsString(int networkTypeAsInt) { @@ -160,5 +177,4 @@ public static String networkTypeAsString(int networkTypeAsInt) { return networkTypeLookup[0]; } } - } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/Destroyable.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/Destroyable.java new file mode 100644 index 000000000..7a599c097 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/Destroyable.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.util; + +public interface Destroyable { + void destroy(); +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/JsonDiffer.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/JsonDiffer.java index 23dce5d9e..e18b77b50 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/JsonDiffer.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/JsonDiffer.java @@ -59,7 +59,7 @@ public static JSONObject getDiff(JSONObject original, JSONObject updated) { // Do nothing. } } catch (JSONException e) { - ApptentiveLog.w("Error diffing object with key %s", e, key); + ApptentiveLog.w(e, "Error diffing object with key %s", key); } finally { it.remove(); } @@ -105,7 +105,7 @@ public static boolean areObjectsEqual(Object left, Object right) { return false; } } catch (JSONException e) { - ApptentiveLog.w("Error comparing JSONObjects", e); + ApptentiveLog.w(e, "Error comparing JSONObjects"); return false; } } @@ -123,7 +123,7 @@ public static boolean areObjectsEqual(Object left, Object right) { } } } catch (JSONException e) { - ApptentiveLog.e("", e); + ApptentiveLog.e(e, ""); return false; } return true; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/Jwt.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/Jwt.java new file mode 100644 index 000000000..a838d13db --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/Jwt.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.util; + +import android.util.Base64; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.UnsupportedEncodingException; + +/** + * Utility class for Jwt handling + */ + +public class Jwt { + private final String alg; + private final String type; + private final JSONObject payload; + + public Jwt(String alg, String type, JSONObject payload) { + if (alg == null) { + throw new IllegalArgumentException("Alg is null"); + } + if (type == null) { + throw new IllegalArgumentException("Type is null"); + } + if (payload == null) { + throw new IllegalArgumentException("Payload is null"); + } + this.alg = alg; + this.type = type; + this.payload = payload; + } + + public static Jwt decode(String data) { + if (data == null) { + throw new IllegalArgumentException("Data string is null"); + } + + final String[] tokens = data.split("\\."); + if (tokens.length != 3) { + throw new IllegalArgumentException("Invalid JWT data format: '" + data + "'"); + } + + final JSONObject headerJson = decodeBase64Json(tokens[0]); + final String alg = headerJson.optString("alg", null); + final String type = headerJson.optString("typ", null); + if (alg == null || type == null) { + throw new IllegalArgumentException("Invalid jwt header: '" + headerJson + "'"); + } + + final JSONObject payloadJson = decodeBase64Json(tokens[1]); + return new Jwt(alg, type, payloadJson); + } + + private static JSONObject decodeBase64Json(String data) { + try { + final String text = new String(Base64.decode(data, Base64.DEFAULT), "UTF-8"); + return new JSONObject(text); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e); + } catch (JSONException e) { + throw new IllegalArgumentException(e); + } + } + + public String getAlg() { + return alg; + } + + public String getType() { + return type; + } + + public JSONObject getPayload() { + return payload; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/ObjectUtils.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/ObjectUtils.java index 2267bff28..45a9c6642 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/ObjectUtils.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/ObjectUtils.java @@ -1,3 +1,24 @@ +// +// ObjectUtils.java +// +// Lunar Unity Mobile Console +// https://github.com/SpaceMadness/lunar-unity-console +// +// Copyright 2017 Alex Lementuev, SpaceMadness. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + package com.apptentive.android.sdk.util; import java.util.HashMap; @@ -28,4 +49,8 @@ public static Map toMap(Object... args) { return map; } -} \ No newline at end of file + + public static boolean equal(Object expected, Object actual) { + return expected != null && actual != null && expected.equals(actual); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/RuntimeUtils.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/RuntimeUtils.java new file mode 100644 index 000000000..4e05c8db5 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/RuntimeUtils.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.util; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; + +import com.apptentive.android.sdk.ApptentiveLog; + +/** + * Collection of helper functions for Android runtime queries. + */ +public class RuntimeUtils { + /** + * Returns true is the app is running in a debug mode + */ + public static boolean isAppDebuggable(Context context) { + if (context == null) { + throw new IllegalArgumentException("Context is null"); + } + + try { + final String appPackageName = context.getPackageName(); + final PackageManager packageManager = context.getPackageManager(); + + PackageInfo packageInfo = packageManager.getPackageInfo(appPackageName, PackageManager.GET_META_DATA | PackageManager.GET_RECEIVERS); + ApplicationInfo ai = packageInfo.applicationInfo; + Bundle metaData = ai.metaData; + if (metaData != null) { + return (ai.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + } catch (Exception e) { + ApptentiveLog.e("Failed to read app's PackageInfo."); + } + + return false; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/StringUtils.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/StringUtils.java index 5fc869074..4724d2419 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/StringUtils.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/StringUtils.java @@ -25,6 +25,7 @@ import org.json.JSONObject; +import java.net.URLEncoder; import java.util.List; import java.util.Map; @@ -113,6 +114,43 @@ public static String join(List list, String separator) { return builder.toString(); } + /** + * Create URL encoded params string from the map of key-value pairs + * + * @throws IllegalArgumentException if map, any key or value appears to be null + */ + public static String createQueryString(Map params) { // FIXME: unit tests (DO NOT ACCEPT PULL REQUEST IF YOU SEE THIS COMMENT) + if (params == null) { + throw new IllegalArgumentException("Params are null"); + } + + StringBuilder result = new StringBuilder(); + for (Map.Entry e : params.entrySet()) { + String key = e.getKey(); + if (key == null) { + throw new IllegalArgumentException("key is null"); + } + + Object valueObj = e.getValue(); + if (valueObj == null) { + throw new IllegalArgumentException("value is null for key '" + key + "'"); + } + + String value = valueObj.toString(); + + @SuppressWarnings("deprecation") + String encodedKey = URLEncoder.encode(key); + @SuppressWarnings("deprecation") + String encodedValue = URLEncoder.encode(value); + + result.append(result.length() == 0 ? "?" : "&"); + result.append(encodedKey); + result.append("="); + result.append(encodedValue); + } + return result.toString(); + } + /** * Checks is string is null or empty */ @@ -120,6 +158,13 @@ public static boolean isNullOrEmpty(String str) { return str == null || str.length() == 0; } + /** + * Safely checks if two strings are equal (any argument can be null) + */ + public static boolean equal(String str1, String str2) { + return str1 != null && str2 != null && str1.equals(str2); + } + /** * Creates a simple json string from key and value */ @@ -133,4 +178,56 @@ public static String asJson(String key, Object value) { return null; } } + + /** + * Converts a hex String to a byte array. + */ + public static byte[] hexToBytes(String hex) { + int length = hex.length(); + byte[] ret = new byte[length / 2]; + for (int i = 0; i < length; i += 2) { + ret[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4) + Character.digit(hex.charAt(i + 1), 16)); + } + return ret; + } + + public static String table(Object[][] rows) { + return table(rows, null); + } + + public static String table(Object[][] rows, String title) { + int[] columnSizes = new int[rows[0].length]; + for (Object[] row : rows) { + for (int i = 0; i < row.length; ++i) { + columnSizes[i] = Math.max(columnSizes[i], toString(row[i]).length()); + } + } + + StringBuilder line = new StringBuilder(); + int totalSize = 0; + for (int i = 0; i < columnSizes.length; ++i) { + totalSize += columnSizes[i]; + } + totalSize += columnSizes.length > 0 ? (columnSizes.length - 1) * " | ".length() : 0; + while (totalSize-- > 0) { + line.append('-'); + } + + StringBuilder result = new StringBuilder(line); + + for (Object[] row : rows) { + result.append("\n"); + + for (int i = 0; i < row.length; ++i) { + if (i > 0) { + result.append(" | "); + } + + result.append(String.format("%-" + columnSizes[i] + "s", row[i])); + } + } + + result.append("\n").append(line); + return result.toString(); + } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/Util.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/Util.java index 188cb3349..b8af91880 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/Util.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/Util.java @@ -25,10 +25,13 @@ import android.graphics.drawable.Drawable; import android.graphics.drawable.StateListDrawable; import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.net.Uri; import android.os.Build; +import android.os.Bundle; import android.os.Environment; import android.provider.MediaStore; +import android.provider.Settings; import android.support.annotation.NonNull; import android.support.v4.content.ContextCompat; import android.text.TextUtils; @@ -36,8 +39,6 @@ import android.view.*; import android.view.inputmethod.InputMethodManager; import android.webkit.URLUtil; -import android.widget.ListAdapter; -import android.widget.ListView; import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; @@ -92,7 +93,13 @@ public static void showSoftKeyboard(Activity activity, View target) { public static boolean isNetworkConnectionPresent() { ConnectivityManager cm = (ConnectivityManager) ApptentiveInternal.getInstance().getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); - return cm != null && cm.getActiveNetworkInfo() != null; + if (cm != null) { + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + if (activeNetwork != null) { + return activeNetwork.isConnectedOrConnecting(); + } + } + return false; } public static void ensureClosed(Closeable stream) { @@ -138,7 +145,7 @@ public static Integer parseCacheControlHeader(String cacheControlHeader) { Integer ret = Integer.parseInt(expiration); return ret; } catch (NumberFormatException e) { - ApptentiveLog.e("Error parsing cache expiration as number: %s", e, expiration); + ApptentiveLog.e(e, "Error parsing cache expiration as number: %s", expiration); } } } @@ -201,7 +208,7 @@ public static String getAppVersionName(Context appContext) { PackageInfo packageInfo = packageManager.getPackageInfo(appContext.getPackageName(), 0); return packageInfo.versionName; } catch (PackageManager.NameNotFoundException e) { - ApptentiveLog.e("Error getting app version name.", e); + ApptentiveLog.e(e, "Error getting app version name."); } return null; } @@ -212,7 +219,7 @@ public static int getAppVersionCode(Context appContext) { PackageInfo packageInfo = packageManager.getPackageInfo(appContext.getPackageName(), 0); return packageInfo.versionCode; } catch (PackageManager.NameNotFoundException e) { - ApptentiveLog.e("Error getting app version code.", e); + ApptentiveLog.e(e, "Error getting app version code."); } return -1; } @@ -272,7 +279,7 @@ public static Integer getMajorOsVersion() { return Integer.parseInt(parts[0]); } } catch (Exception e) { - ApptentiveLog.w("Error getting major OS version", e); + ApptentiveLog.w(e, "Error getting major OS version"); } return -1; } @@ -521,6 +528,10 @@ private static String md5(String s) { return null; } + public static String generateRandomFilename() { + return UUID.randomUUID().toString(); + } + /* * Generate cached file name use md5 from image originalPath and image created time */ @@ -619,7 +630,7 @@ && hasPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { context.startActivity(intent); return true; } catch (ActivityNotFoundException e) { - ApptentiveLog.e("Activity not found to open attachment: ", e); + ApptentiveLog.e(e, "Activity not found to open attachment: "); } } } else { @@ -663,6 +674,110 @@ public static int copyFile(String from, String to) { } } + public static void writeBytes(File file, byte[] bytes) throws IOException { + if (file == null) { + throw new IllegalArgumentException("'file' is null"); + } + + if (bytes == null) { + throw new IllegalArgumentException("'bytes' is null"); + } + + File parentFile = file.getParentFile(); + if (!parentFile.exists() && !parentFile.mkdirs()) { + throw new IOException("Parent file could not be created: " + parentFile); + } + + ByteArrayInputStream input = null; + FileOutputStream output = null; + try { + input = new ByteArrayInputStream(bytes); + output = new FileOutputStream(file); + copy(input, output); + } finally { + ensureClosed(input); + ensureClosed(output); + } + } + + public static byte[] readBytes(File file) throws IOException { + ByteArrayOutputStream output = null; + try { + output = new ByteArrayOutputStream(); + appendFileToStream(file, output); + return output.toByteArray(); + } finally { + ensureClosed(output); + } + } + + public static void appendFileToStream(File file, OutputStream outputStream) throws IOException { + if (file == null) { + throw new IllegalArgumentException("'file' is null"); + } + + if (!file.exists()) { + throw new FileNotFoundException("File does not exist: " + file); + } + + if (file.isDirectory()) { + throw new FileNotFoundException("File is directory: " + file); + } + + FileInputStream input = null; + try { + input = new FileInputStream(file); + copy(input, outputStream); + } finally { + ensureClosed(input); + } + } + + private static void copy(InputStream input, OutputStream output) throws IOException { + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = input.read(buffer)) > 0) { + output.write(buffer, 0, bytesRead); + } + } + + public static void writeNullableUTF(DataOutput out, String value) throws IOException { + out.writeBoolean(value != null); + if (value != null) { + out.writeUTF(value); + } + } + + public static String readNullableUTF(DataInput in) throws IOException { + boolean notNull = in.readBoolean(); + return notNull ? in.readUTF() : null; + } + + public static void writeNullableBoolean(DataOutput out, Boolean value) throws IOException { + out.writeBoolean(value != null); + if (value != null) { + out.writeBoolean(value); + } + } + + public static Boolean readNullableBoolean(DataInput in) throws IOException { + boolean notNull = in.readBoolean(); + return notNull ? in.readBoolean() : null; + } + + public static void writeNullableDouble(DataOutput out, Double value) throws IOException { + out.writeBoolean(value != null); + if (value != null) { + out.writeDouble(value); + } + } + + public static Double readNullableDouble(DataInput in) throws IOException { + boolean notNull = in.readBoolean(); + return notNull ? in.readDouble() : null; + } + + public static boolean isMimeTypeImage(String mimeType) { if (TextUtils.isEmpty(mimeType)) { return false; @@ -805,9 +920,9 @@ public static void replaceDefaultFont(Context context, String fontFilePath) { staticField.setAccessible(true); staticField.set(null, newMap); } catch (NoSuchFieldException e) { - ApptentiveLog.e("Exception replacing system font", e); + ApptentiveLog.e(e, "Exception replacing system font"); } catch (IllegalAccessException e) { - ApptentiveLog.e("Exception replacing system font", e); + ApptentiveLog.e(e, "Exception replacing system font"); } } } else { @@ -825,11 +940,69 @@ public static void replaceDefaultFont(Context context, String fontFilePath) { staticField.setAccessible(true); staticField.set(null, newTypeface); } catch (NoSuchFieldException e) { - ApptentiveLog.e("Exception replacing system font", e); + ApptentiveLog.e(e, "Exception replacing system font"); } catch (IllegalAccessException e) { - ApptentiveLog.e("Exception replacing system font", e); + ApptentiveLog.e(e, "Exception replacing system font"); } } } } + + public static String humanReadableByteCount(long bytes, boolean si) { + int unit = si ? 1000 : 1024; + if (bytes < unit) return bytes + " B"; + int exp = (int) (Math.log(bytes) / Math.log(unit)); + String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i"); + return String.format("%.1f %sB", bytes / Math.pow(unit, exp), pre); + } + + public static String getAndroidId(Context context) { + if (context == null) { + return null; + } + return Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID); + } + /** + * Returns and internal storage directory + */ + public static File getInternalDir(Context context, String path, boolean createIfNecessary) { + File filesDir = context.getFilesDir(); + File internalDir = new File(filesDir, path); + if (!internalDir.exists() && createIfNecessary) { + boolean succeed = internalDir.mkdirs(); + if (!succeed) { + ApptentiveLog.w("Unable to create internal directory: %s", internalDir); + } + } + return internalDir; + } + + /** + * Helper method for resolving manifest metadata string value + * + * @return null if key is missing or exception is thrown + */ + public static String getManifestMetadataString(Context context, String key) { + if (context == null) { + throw new IllegalArgumentException("Context is null"); + } + + if (key == null) { + throw new IllegalArgumentException("Key is null"); + } + + try { + String appPackageName = context.getPackageName(); + PackageManager packageManager = context.getPackageManager(); + PackageInfo packageInfo = packageManager.getPackageInfo(appPackageName, PackageManager.GET_META_DATA | PackageManager.GET_RECEIVERS); + Bundle metaData = packageInfo.applicationInfo.metaData; + if (metaData != null) { + return Util.trim(metaData.getString(key)); + } + } catch (Exception e) { + ApptentiveLog.e(e, "Unexpected error while reading application or package info."); + } + + return null; + } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ApptentiveAttachmentLoader.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ApptentiveAttachmentLoader.java index 67fb5d9be..3f2c925df 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ApptentiveAttachmentLoader.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ApptentiveAttachmentLoader.java @@ -6,12 +6,6 @@ package com.apptentive.android.sdk.util.image; -import java.lang.ref.WeakReference; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.concurrent.RejectedExecutionException; - import android.annotation.SuppressLint; import android.graphics.Bitmap; import android.os.AsyncTask; @@ -27,6 +21,12 @@ import com.apptentive.android.sdk.util.task.ApptentiveDownloaderTask; import com.apptentive.android.sdk.util.task.ApptentiveDrawableLoaderTask; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.concurrent.RejectedExecutionException; + public class ApptentiveAttachmentLoader { public static final int DRAWABLE_DOWNLOAD_TAG = R.id.apptentive_drawable_downloader; @@ -217,7 +217,7 @@ public void doDownload() { try { ApptentiveLog.v("ApptentiveAttachmentLoader doDownload: " + uri); // Conversation token is needed if the download url is a redirect link from an Apptentive endpoint - String conversationToken = ApptentiveInternal.getInstance().getApptentiveConversationToken(); + String conversationToken = ApptentiveInternal.getInstance().getConversation().getConversationToken(); // FIXME: get rid of singleton if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { mDrawableDownloaderTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, uri, diskCacheFilePath, conversationToken); } else { diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ImageUtil.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ImageUtil.java index faa946dad..f72fb7c14 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ImageUtil.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/image/ImageUtil.java @@ -29,6 +29,7 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.lang.ref.WeakReference; import java.net.URL; @@ -288,10 +289,10 @@ public static boolean createScaledDownImageCacheFile(String sourcePath, String c smaller.recycle(); System.gc(); } catch (FileNotFoundException e) { - ApptentiveLog.e("File not found while storing image.", e); + ApptentiveLog.e(e, "File not found while storing image."); return false; } catch (Exception e) { - ApptentiveLog.a("Error storing image.", e); + ApptentiveLog.a(e, "Error storing image."); return false; } finally { Util.ensureClosed(cos); @@ -300,6 +301,34 @@ public static boolean createScaledDownImageCacheFile(String sourcePath, String c return true; } + public static boolean appendScaledDownImageToStream(String sourcePath, OutputStream outputStream) { + // Retrieve image orientation + int imageOrientation = 0; + try { + ExifInterface exif = new ExifInterface(sourcePath); + imageOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + } catch (IOException e) { + } + + // Copy the file contents over. + CountingOutputStream cos = null; + try { + cos = new CountingOutputStream(new BufferedOutputStream(outputStream)); + System.gc(); + Bitmap smaller = ImageUtil.createScaledBitmapFromLocalImageSource(sourcePath, MAX_SENT_IMAGE_EDGE, MAX_SENT_IMAGE_EDGE, null, imageOrientation); + smaller.compress(Bitmap.CompressFormat.JPEG, 95, cos); + cos.flush(); + ApptentiveLog.v("Bitmap bytes appended, size = " + (cos.getBytesWritten() / 1024) + "k"); + smaller.recycle(); + return true; + } catch (Exception e) { + ApptentiveLog.a(e, "Error storing image."); + return false; + } finally { + Util.ensureClosed(cos); + } + } + private static class DownloadImageTask extends AsyncTask { private WeakReference resultView; diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/task/ApptentiveDownloaderTask.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/task/ApptentiveDownloaderTask.java index 65470057f..d3466674f 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/task/ApptentiveDownloaderTask.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/task/ApptentiveDownloaderTask.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -26,11 +26,9 @@ import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.comm.ApptentiveClient; import com.apptentive.android.sdk.comm.ApptentiveHttpResponse; +import com.apptentive.android.sdk.util.Constants; import com.apptentive.android.sdk.util.Util; -/** - * @author Barry Li - */ public class ApptentiveDownloaderTask extends AsyncTask { private static boolean FILE_DOWNLOAD_REDIRECTION_ENABLED = false; @@ -68,7 +66,7 @@ protected ApptentiveHttpResponse doInBackground(Object... params) { try { finished = downloadBitmap((String) params[0], (String) params[1], (String) params[2]); } catch (Exception e) { - ApptentiveLog.d("Error downloading bitmap", e); + ApptentiveLog.d(e, "Error downloading bitmap"); } return finished; } @@ -134,13 +132,13 @@ private ApptentiveHttpResponse downloadBitmap(String urlString, String destFileP if (bRequestRedirectThroughApptentive) { connection.setRequestProperty("User-Agent", ApptentiveClient.getUserAgentString()); connection.setRequestProperty("Authorization", "OAuth " + conversationToken); - connection.setRequestProperty("X-API-Version", String.valueOf(ApptentiveClient.API_VERSION)); + connection.setRequestProperty("X-API-Version", String.valueOf(Constants.API_VERSION)); } else if (cookies != null) { connection.setRequestProperty("Cookie", cookies); } - connection.setConnectTimeout(ApptentiveClient.DEFAULT_HTTP_CONNECT_TIMEOUT); - connection.setReadTimeout(ApptentiveClient.DEFAULT_HTTP_SOCKET_TIMEOUT); + connection.setConnectTimeout(Constants.DEFAULT_CONNECT_TIMEOUT_MILLIS); + connection.setReadTimeout(Constants.DEFAULT_READ_TIMEOUT_MILLIS); connection.setRequestProperty("Accept-Encoding", "gzip"); connection.setRequestProperty("Accept", "application/json"); @@ -226,18 +224,18 @@ private ApptentiveHttpResponse downloadBitmap(String urlString, String destFileP } } } catch (IllegalArgumentException e) { - ApptentiveLog.w("Error communicating with server.", e); + ApptentiveLog.w(e, "Error communicating with server."); } catch (SocketTimeoutException e) { - ApptentiveLog.w("Timeout communicating with server.", e); + ApptentiveLog.w(e, "Timeout communicating with server."); } catch (final MalformedURLException e) { - ApptentiveLog.w("ClientProtocolException", e); + ApptentiveLog.w(e, "ClientProtocolException"); } catch (final IOException e) { - ApptentiveLog.w("ClientProtocolException", e); + ApptentiveLog.w(e, "ClientProtocolException"); // Read the error response. try { ret.setContent(ApptentiveClient.getErrorResponse(connection, ret.isZipped())); } catch (IOException ex) { - ApptentiveLog.w("Can't read error stream.", ex); + ApptentiveLog.w(ex, "Can't read error stream."); } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueue.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueue.java index 7bf13d58c..14e5d02e6 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueue.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/ConcurrentDispatchQueue.java @@ -56,7 +56,7 @@ public void stop() { @Override public Thread newThread(Runnable r) { - return new Thread(r, name + "-thread-" + threadNumber.getAndIncrement()); + return new Thread(r, name + " (thread-" + threadNumber.getAndIncrement() + ")"); } //endregion diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchQueue.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchQueue.java index c001f1e96..55f083a0e 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchQueue.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchQueue.java @@ -84,7 +84,21 @@ public boolean dispatchAsyncOnce(DispatchTask task, long delayMillis) { * A global dispatch queue associated with main thread */ public static DispatchQueue mainQueue() { - return Holder.INSTANCE; + return Holder.MAIN_QUEUE; + } + + /** + * Returns true if code is executing on the main queue + */ + public static boolean isMainQueue() { + return Looper.getMainLooper() == Looper.myLooper(); // FIXME: make it configurable for Unit testing + } + + /** + * A global dispatch concurrent queue + */ + public static DispatchQueue backgroundQueue() { + return Holder.BACKGROUND_QUEUE; } /** @@ -105,7 +119,8 @@ public static DispatchQueue createBackgroundQueue(String name, DispatchQueueType * Thread safe singleton trick */ private static class Holder { - private static final DispatchQueue INSTANCE = createMainQueue(); + private static final DispatchQueue MAIN_QUEUE = createMainQueue(); + private static final DispatchQueue BACKGROUND_QUEUE = createBackgroundQueue(); private static DispatchQueue createMainQueue() { try { @@ -116,5 +131,9 @@ private static DispatchQueue createMainQueue() { return null; } } + + private static DispatchQueue createBackgroundQueue() { + return new ConcurrentDispatchQueue("Apptentive Background Queue"); + } } } \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchTask.java b/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchTask.java index e854f6dff..00afe7a27 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchTask.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/threading/DispatchTask.java @@ -8,6 +8,8 @@ import com.apptentive.android.sdk.ApptentiveLog; +import static com.apptentive.android.sdk.debug.Tester.dispatchException; + /** * A basic class for any dispatch runnable task. Tracks its "schedule" state */ @@ -26,10 +28,12 @@ public abstract class DispatchTask implements Runnable { @Override public void run() { try { - setScheduled(false); execute(); } catch (Exception e) { ApptentiveLog.e(e, "Exception while executing task"); + dispatchException(e); + } finally { + setScheduled(false); } } @@ -37,7 +41,7 @@ synchronized void setScheduled(boolean scheduled) { this.scheduled = scheduled; } - synchronized boolean isScheduled() { + public synchronized boolean isScheduled() { return scheduled; } } \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/view/ApptentiveAlertDialog.java b/apptentive/src/main/java/com/apptentive/android/sdk/view/ApptentiveAlertDialog.java index 477337df4..8764909a9 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/view/ApptentiveAlertDialog.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/view/ApptentiveAlertDialog.java @@ -93,7 +93,7 @@ public void onClick(View v) { }); } } catch (Exception e) { - ApptentiveLog.e("Error:", e); + ApptentiveLog.e(e, "Error:"); } AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); if (view != null) { diff --git a/apptentive/src/main/res/drawable/apptentive_ic_action_attach_auto_mirror.xml b/apptentive/src/main/res/drawable/apptentive_ic_action_attach_auto_mirror.xml new file mode 100644 index 000000000..e51201143 --- /dev/null +++ b/apptentive/src/main/res/drawable/apptentive_ic_action_attach_auto_mirror.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/apptentive/src/main/res/drawable/apptentive_ic_action_send_auto_mirror.xml b/apptentive/src/main/res/drawable/apptentive_ic_action_send_auto_mirror.xml new file mode 100644 index 000000000..de4ebe320 --- /dev/null +++ b/apptentive/src/main/res/drawable/apptentive_ic_action_send_auto_mirror.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/apptentive/src/main/res/drawable/apptentive_ic_compose_auto_mirror.xml b/apptentive/src/main/res/drawable/apptentive_ic_compose_auto_mirror.xml new file mode 100644 index 000000000..852f38834 --- /dev/null +++ b/apptentive/src/main/res/drawable/apptentive_ic_compose_auto_mirror.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/apptentive/src/main/res/layout/apptentive_message_center.xml b/apptentive/src/main/res/layout/apptentive_message_center.xml index e6f3d4442..805b89166 100644 --- a/apptentive/src/main/res/layout/apptentive_message_center.xml +++ b/apptentive/src/main/res/layout/apptentive_message_center.xml @@ -25,6 +25,6 @@ android:layout_height="wrap_content" style="?attr/apptentiveFabStyle" android:visibility="gone" - android:src="@drawable/apptentive_ic_compose" + android:src="@drawable/apptentive_ic_compose_auto_mirror" android:contentDescription="@string/apptentive_message_center_fab_content_description"/> \ No newline at end of file diff --git a/apptentive/src/main/res/layout/apptentive_message_center_composer.xml b/apptentive/src/main/res/layout/apptentive_message_center_composer.xml index c6c4fff13..eb3e965fb 100644 --- a/apptentive/src/main/res/layout/apptentive_message_center_composer.xml +++ b/apptentive/src/main/res/layout/apptentive_message_center_composer.xml @@ -50,7 +50,7 @@ android:layout_alignParentEnd="true" style="@style/Apptentive.Style.Widget.ImageButton.Composing" android:minWidth="48dp" - android:src="@drawable/apptentive_ic_action_send" + android:src="@drawable/apptentive_ic_action_send_auto_mirror" android:scaleType="center" android:contentDescription="@string/apptentive_message_center_content_description_send_button"/> diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/debug/AssertTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/debug/AssertTest.java new file mode 100644 index 000000000..8076a0098 --- /dev/null +++ b/apptentive/src/test/java/com/apptentive/android/sdk/debug/AssertTest.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.debug; + +import com.apptentive.android.sdk.TestCaseBase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class AssertTest extends TestCaseBase implements AssertImp { + + //region Setup + + @Before + public void setUp() { + AssertEx.setImp(this); + } + + @After + public void tearDown() { + AssertEx.setImp(null); + } + + //endregion + + @Test + public void testAssertTrue() throws Exception { + AssertEx.assertTrue(true); + AssertEx.assertTrue(false); + AssertEx.assertTrue(true, ""); + AssertEx.assertTrue(false, "assertTrue(boolean,String)"); + AssertEx.assertTrue(true, "", new Object()); + AssertEx.assertTrue(false, "assertTrue(boolean,String,Object...)"); + + assertResult( + "Expected 'true' but was 'false'", + "assertTrue(boolean,String)", + "assertTrue(boolean,String,Object...)" + ); + } + + @Test + public void testAssertFalse() throws Exception { + AssertEx.assertFalse(false); + AssertEx.assertFalse(true); + AssertEx.assertFalse(false, ""); + AssertEx.assertFalse(true, "assertFalse(boolean,String)"); + AssertEx.assertFalse(false, "", new Object()); + AssertEx.assertFalse(true, "assertFalse(boolean,String,Object...)"); + + assertResult( + "Expected 'false' but was 'true'", + "assertFalse(boolean,String)", + "assertFalse(boolean,String,Object...)" + ); + } + + @Test + public void testAssertNotNull() throws Exception { + AssertEx.assertNotNull(new Object()); + AssertEx.assertNotNull(null); + AssertEx.assertNotNull(new Object(), ""); + AssertEx.assertNotNull(null, "assertNotNull(Object,String)"); + AssertEx.assertNotNull(new Object(), "", new Object()); + AssertEx.assertNotNull(null, "assertNotNull(Object,String,Object...)"); + + assertResult( + "Not-null expected", + "assertNotNull(Object,String)", + "assertNotNull(Object,String,Object...)" + ); + } + + @Test + public void testAssertNull() throws Exception { + AssertEx.assertNull(null); + AssertEx.assertNull("foo"); + AssertEx.assertNull(null); + AssertEx.assertNull("foo", "assertNull(Object,String)"); + AssertEx.assertNull(null, "", new Object()); + AssertEx.assertNull("foo", "assertNull(Object,String,Object...)"); + + assertResult( + "Expected 'null' but was 'foo'", + "assertNull(Object,String)", + "assertNull(Object,String,Object...)" + ); + } + + @Test + public void testAssertEquals() throws Exception { + AssertEx.assertEquals("foo", "foo"); + AssertEx.assertEquals("foo", "bar"); + assertResult("Expected 'foo' but was 'bar'"); + } + + @Override + public void assertFailed(String message) { + addResult(message); + } + + private static class AssertEx extends com.apptentive.android.sdk.debug.Assert + { + } +} \ No newline at end of file diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenterTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenterTest.java index a55c47f0a..ff770ee2c 100644 --- a/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenterTest.java +++ b/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationCenterTest.java @@ -9,8 +9,8 @@ import com.apptentive.android.sdk.TestCaseBase; import com.apptentive.android.sdk.util.ObjectUtils; import com.apptentive.android.sdk.util.StringUtils; -import com.apptentive.android.sdk.util.threading.MockDispatchQueue; +import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -19,8 +19,14 @@ public class ApptentiveNotificationCenterTest extends TestCaseBase { private ApptentiveNotificationCenter notificationCenter; @Before - public void setUp() throws Exception { - notificationCenter = new ApptentiveNotificationCenter(new MockDispatchQueue(true), new MockDispatchQueue(true)); + public void setUp() { + super.setUp(); + notificationCenter = new ApptentiveNotificationCenter(); + } + + @After + public void tearDown() { + super.tearDown(); } private static final boolean WEAK_REFERENCE = true; diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserverListTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserverListTest.java index 836a640ff..80c955076 100644 --- a/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserverListTest.java +++ b/apptentive/src/test/java/com/apptentive/android/sdk/notifications/ApptentiveNotificationObserverListTest.java @@ -8,6 +8,8 @@ import com.apptentive.android.sdk.TestCaseBase; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import java.util.HashMap; @@ -18,6 +20,16 @@ public class ApptentiveNotificationObserverListTest extends TestCaseBase { private static final boolean WEAK_REFERENCE = true; private static final boolean STRONG_REFERENCE = false; + @Before + public void setUp() { + super.setUp(); + } + + @After + public void tearDown() { + super.tearDown(); + } + @Test public void testAddObservers() { ApptentiveNotificationObserverList list = new ApptentiveNotificationObserverList(); diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/serialization/ObjectSerializationTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/serialization/ObjectSerializationTest.java new file mode 100644 index 000000000..75c46d491 --- /dev/null +++ b/apptentive/src/test/java/com/apptentive/android/sdk/serialization/ObjectSerializationTest.java @@ -0,0 +1,65 @@ +package com.apptentive.android.sdk.serialization; + +import org.junit.Before; +import org.junit.Test; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.File; +import java.io.IOException; + +import static org.junit.Assert.assertEquals; + +public class ObjectSerializationTest { + + private File file; + + @Before + public void setUp() throws IOException { + file = File.createTempFile("dummy", ".bin"); + file.deleteOnExit(); + } + + @Test + public void testSerialization() throws IOException { + + Dummy expected = new Dummy("Some value"); + ObjectSerialization.serialize(file, expected); + Dummy actual = ObjectSerialization.deserialize(file, Dummy.class); + assertEquals(expected, actual); + } + + static class Dummy implements SerializableObject { + + private final String value; + + public Dummy(String value) { + this.value = value; + } + + public Dummy(DataInput in) throws IOException { + value = in.readUTF(); + } + + @Override + public void writeExternal(DataOutput out) throws IOException { + out.writeUTF(value); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Dummy dummy = (Dummy) o; + + return value.equals(dummy.value); + + } + + @Override + public int hashCode() { + return value.hashCode(); + } + } +} \ No newline at end of file diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/storage/ConversationDataTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/storage/ConversationDataTest.java new file mode 100644 index 000000000..194f714ae --- /dev/null +++ b/apptentive/src/test/java/com/apptentive/android/sdk/storage/ConversationDataTest.java @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.conversation.ConversationData; +import com.apptentive.android.sdk.util.Util; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + +import static org.junit.Assert.*; + +public class ConversationDataTest { + + @Rule + public TemporaryFolder conversationFolder = new TemporaryFolder(); + + @Test + public void testSerialization() { + ConversationData expected = new ConversationData(); + expected.setConversationId("jvnuveanesndndnadldbj"); + expected.setConversationToken("watgsiovncsagjmcneiusdolnfcs"); + expected.setLastSeenSdkVersion("mdvnjfuoivsknbjgfaoskdl"); + expected.setMessageCenterFeatureUsed(true); + expected.setMessageCenterWhoCardPreviouslyDisplayed(false); + expected.setMessageCenterPendingMessage("`~!@#$%^&*(_+{}:\"'<>?!@#$%^&*()_+{}|:<>?"); + expected.setMessageCenterPendingAttachments("NVBUOIVKNBGFANWKSLBJK"); + expected.setTargets("MNCIUFIENVBFKDV"); + expected.setInteractions("nkjvdfikjbffasldnbnfldfmfd"); + expected.setInteractionExpiration(1234567894567890345L); + + /* + // TODO: Test nested objects as well + */ + + + ByteArrayOutputStream baos = null; + ObjectOutputStream oos = null; + + ByteArrayInputStream bais = null; + ObjectInputStream ois = null; + + try { + baos = new ByteArrayOutputStream(); + oos = new ObjectOutputStream(baos); + oos.writeObject(expected); + + bais = new ByteArrayInputStream(baos.toByteArray()); + ois = new ObjectInputStream(bais); + + ConversationData actual = (ConversationData) ois.readObject(); + assertEquals(expected.getConversationId(), actual.getConversationId()); + assertEquals(expected.getConversationToken(), actual.getConversationToken()); + assertEquals(expected.getLastSeenSdkVersion(), actual.getLastSeenSdkVersion()); + assertEquals(expected.isMessageCenterFeatureUsed(), actual.isMessageCenterFeatureUsed()); + assertEquals(expected.isMessageCenterWhoCardPreviouslyDisplayed(), actual.isMessageCenterWhoCardPreviouslyDisplayed()); + assertEquals(expected.getMessageCenterPendingMessage(), actual.getMessageCenterPendingMessage()); + assertEquals(expected.getMessageCenterPendingAttachments(), actual.getMessageCenterPendingAttachments()); + assertEquals(expected.getTargets(), actual.getTargets()); + assertEquals(expected.getInteractions(), actual.getInteractions()); + assertEquals(expected.getInteractionExpiration(), actual.getInteractionExpiration(), 0.000001); + + } catch (Exception e) { + fail(e.getMessage()); + } finally { + Util.ensureClosed(baos); + Util.ensureClosed(oos); + Util.ensureClosed(bais); + Util.ensureClosed(ois); + } + } + + private boolean listenerFired; // TODO: get rid of this field and make it test "local" + + @Test + public void testDataChangeListeners() throws Exception { + ConversationData data = new ConversationData(); + testConversationListeners(data); + } + + @Test + public void testDataChangeListenersWhenDeserialized() throws Exception { + ConversationData data = new ConversationData(); + + File conversationFile = new File(conversationFolder.getRoot(), "conversation.bin"); + new FileSerializer(conversationFile).serialize(data); + + data = (ConversationData) new FileSerializer(conversationFile).deserialize(); + testConversationListeners(data); + } + + private void testConversationListeners(ConversationData data) { + data.setDataChangedListener(new DataChangedListener() { + @Override + public void onDataChanged() { + listenerFired = true; + } + }); + listenerFired = false; + + data.setConversationToken("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setConversationId("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setLastSeenSdkVersion("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setMessageCenterFeatureUsed(true); + assertTrue(listenerFired); + listenerFired = false; + + data.setMessageCenterWhoCardPreviouslyDisplayed(true); + assertTrue(listenerFired); + listenerFired = false; + + data.setMessageCenterPendingMessage("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setMessageCenterPendingAttachments("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setInteractions("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setTargets("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setInteractionExpiration(1000L); + assertTrue(listenerFired); + listenerFired = false; + + + data.getDevice().getCustomData().put("foo", "bar"); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().getCustomData().remove("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setDevice(new Device()); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().getCustomData().put("foo", "bar"); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().getIntegrationConfig().setAmazonAwsSns(new IntegrationConfigItem()); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().setIntegrationConfig(new IntegrationConfig()); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().getIntegrationConfig().setAmazonAwsSns(new IntegrationConfigItem()); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().setOsApiLevel(5); + assertTrue(listenerFired); + listenerFired = false; + + data.getDevice().setUuid("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setLastSentDevice(new Device()); + assertTrue(listenerFired); + listenerFired = false; + + data.getLastSentDevice().setUuid("foo"); + assertTrue(listenerFired); + listenerFired = false; + + + data.setPerson(new Person()); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setId("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setEmail("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setName("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setFacebookId("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setPhoneNumber("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setStreet("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setCity("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setZip("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setCountry("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setBirthday("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().setCustomData(new CustomData()); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().getCustomData().put("foo", "bar"); + assertTrue(listenerFired); + listenerFired = false; + + data.getPerson().getCustomData().remove("foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setLastSentPerson(new Person()); + assertTrue(listenerFired); + listenerFired = false; + + data.getLastSentPerson().setId("foo"); + assertTrue(listenerFired); + listenerFired = false; + + + data.setSdk(new Sdk()); + assertTrue(listenerFired); + listenerFired = false; + + data.setAppRelease(new AppRelease()); + assertTrue(listenerFired); + listenerFired = false; + + + data.getVersionHistory().updateVersionHistory(100D, 1, "1"); + assertTrue(listenerFired); + listenerFired = false; + + data.setVersionHistory(new VersionHistory()); + assertTrue(listenerFired); + listenerFired = false; + + data.getVersionHistory().updateVersionHistory(100D, 1, "1"); + assertTrue(listenerFired); + listenerFired = false; + + data.getEventData().storeEventForCurrentAppVersion(100D, 10, "1.0", "foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getEventData().storeInteractionForCurrentAppVersion(100D, 10, "1.0", "foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.setEventData(new EventData()); + assertTrue(listenerFired); + listenerFired = false; + + data.getEventData().storeEventForCurrentAppVersion(100D, 10, "1.0", "foo"); + assertTrue(listenerFired); + listenerFired = false; + + data.getEventData().storeInteractionForCurrentAppVersion(100D, 10, "1.0", "foo"); + assertTrue(listenerFired); + listenerFired = false; + } + + // TODO: Add a test for verifying that setting an existing value doesn't fire listeners. +} \ No newline at end of file diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/storage/JsonPayloadSenderTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/storage/JsonPayloadSenderTest.java new file mode 100644 index 000000000..822c2143c --- /dev/null +++ b/apptentive/src/test/java/com/apptentive/android/sdk/storage/JsonPayloadSenderTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.storage; + +import com.apptentive.android.sdk.TestCaseBase; +import com.apptentive.android.sdk.model.PayloadData; +import com.apptentive.android.sdk.model.PayloadType; +import com.apptentive.android.sdk.network.HttpRequest; +import com.apptentive.android.sdk.network.HttpRequestManager; +import com.apptentive.android.sdk.network.HttpRequestMethod; +import com.apptentive.android.sdk.network.HttpRequestRetryPolicyDefault; +import com.apptentive.android.sdk.network.MockHttpRequest; +import com.apptentive.android.sdk.network.MockHttpURLConnection.DefaultResponseHandler; +import com.apptentive.android.sdk.network.MockHttpURLConnection.ResponseHandler; +import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.util.threading.MockDispatchQueue; + +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; + +import static junit.framework.Assert.assertFalse; +import static junit.framework.TestCase.assertTrue; + +public class JsonPayloadSenderTest extends TestCaseBase { + private MockDispatchQueue networkQueue; + + @Before + public void setUp() { + super.setUp(); + networkQueue = new MockDispatchQueue(false); + } + + @Test + public void testSendPayload() throws Exception { + + final MockPayloadRequestSender requestSender = new MockPayloadRequestSender(); + + PayloadSender sender = new PayloadSender(requestSender, new HttpRequestRetryPolicyDefault()); + sender.setListener(new PayloadSender.Listener() { + @Override + public void onFinishSending(PayloadSender sender, PayloadData payload, boolean cancelled, String errorMessage, int responseCode, JSONObject responseData) { + if (cancelled) { + addResult("cancelled: " + payload); + } else if (errorMessage != null) { + addResult("failed: " + payload + " " + errorMessage); + } else { + addResult("succeed: " + payload); + } + } + }); + + final MockPayload payload1 = new MockPayload("key1", "value1"); + final MockPayload payload2 = new MockPayload("key2", "value2").setResponseHandler(new DefaultResponseHandler() { + int retryAttempt = 0; + + @Override + public int getResponseCode() { + return ++retryAttempt > 1 ? 200 : 500; + } + }); + final MockPayload payload3 = new MockPayload("key3", "value3").setResponseCode(400); + + assertTrue(sender.sendPayload(payload1)); + assertFalse(sender.sendPayload(payload2)); // would not start sending until the first one is complete + assertFalse(sender.sendPayload(payload3)); // would not start sending until the first one is complete + + networkQueue.dispatchTasks(); + assertResult( + "succeed: {'key1':'value1'}" + ); + + assertTrue(sender.sendPayload(payload2)); + assertFalse(sender.sendPayload(payload3)); // would not start sending until the first one is complete + + networkQueue.dispatchTasks(); + assertResult( + "succeed: {'key2':'value2'}" // NOTE: this request would succeed on the second attempt + ); + + assertTrue(sender.sendPayload(payload3)); + + networkQueue.dispatchTasks(); + assertResult( + "failed: {'key3':'value3'} Unexpected response code: 400 (Bad Request)" + ); + } + + class MockPayload extends PayloadData { + private final String json; + private ResponseHandler responseHandler; + + public MockPayload(String key, Object value) { + super(PayloadType.unknown, "nonce", "conversationId", new byte[0], "authToken", "contentType", "path", HttpRequestMethod.GET, false); // TODO: figure out a better type + + json = StringUtils.format("{'%s':'%s'}", key, value); + responseHandler = new DefaultResponseHandler(); + } + + public MockPayload setResponseCode(int responseCode) { + ((DefaultResponseHandler)responseHandler).setResponseCode(responseCode); + return this; + } + + public MockPayload setResponseHandler(ResponseHandler responseHandler) { + this.responseHandler = responseHandler; + return this; + } + + public ResponseHandler getResponseHandler() { + return responseHandler; + } + + @Override + public String toString() { + return json; + } + } + + class MockPayloadRequestSender implements PayloadRequestSender { + private final HttpRequestManager requestManager; + + public MockPayloadRequestSender() { + requestManager = new HttpRequestManager(networkQueue); + } + + @Override + public HttpRequest createPayloadSendRequest(PayloadData payload, HttpRequest.Listener listener) { + MockHttpRequest request = new MockHttpRequest("http://apptentive.com"); + request.setMockResponseHandler(((MockPayload) payload).getResponseHandler()); + request.addListener(listener); + request.setRequestManager(requestManager); + return request; + } + } +} \ No newline at end of file diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/util/threading/DispatchQueueTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/util/threading/DispatchQueueTest.java index 466fe8324..aaabf2984 100644 --- a/apptentive/src/test/java/com/apptentive/android/sdk/util/threading/DispatchQueueTest.java +++ b/apptentive/src/test/java/com/apptentive/android/sdk/util/threading/DispatchQueueTest.java @@ -8,6 +8,7 @@ import com.apptentive.android.sdk.TestCaseBase; +import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -17,9 +18,15 @@ public class DispatchQueueTest extends TestCaseBase { @Before public void setUp() { + super.setUp(); overrideMainQueue(false); } + @After + public void tearDown() { + super.tearDown(); + } + @Test public void testSchedulingTasks() { DispatchTask task = new DispatchTask() { diff --git a/apptentive/src/testCommon/java/com/apptentive/android/sdk/ApptentiveInternalMock.java b/apptentive/src/testCommon/java/com/apptentive/android/sdk/ApptentiveInternalMock.java new file mode 100644 index 000000000..b1a1adaf0 --- /dev/null +++ b/apptentive/src/testCommon/java/com/apptentive/android/sdk/ApptentiveInternalMock.java @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk; + +public class ApptentiveInternalMock extends ApptentiveInternal { + public ApptentiveInternalMock() { + } +} diff --git a/apptentive/src/testCommon/java/com/apptentive/android/sdk/MockDispatchQueue.java b/apptentive/src/testCommon/java/com/apptentive/android/sdk/MockDispatchQueue.java index c55a1bcac..e7c17b6f1 100644 --- a/apptentive/src/testCommon/java/com/apptentive/android/sdk/MockDispatchQueue.java +++ b/apptentive/src/testCommon/java/com/apptentive/android/sdk/MockDispatchQueue.java @@ -35,10 +35,9 @@ public void stop() { } public void dispatchTasks() { - for (DispatchTask task : tasks) { - task.run(); + while (tasks.size() > 0) { + tasks.poll().run(); } - tasks.clear(); } public static MockDispatchQueue overrideMainQueue(boolean runImmediately) { @@ -50,7 +49,7 @@ public static MockDispatchQueue overrideMainQueue(boolean runImmediately) { private static void overrideMainQueue(DispatchQueue queue) { try { Class holderClass = DispatchQueue.class.getDeclaredClasses()[0]; - Field instanceField = holderClass.getDeclaredField("INSTANCE"); + Field instanceField = holderClass.getDeclaredField("MAIN_QUEUE"); instanceField.setAccessible(true); Field modifiersField = Field.class.getDeclaredField("modifiers"); diff --git a/apptentive/src/testCommon/java/com/apptentive/android/sdk/TestCaseBase.java b/apptentive/src/testCommon/java/com/apptentive/android/sdk/TestCaseBase.java index 891b42856..e8b712b1c 100644 --- a/apptentive/src/testCommon/java/com/apptentive/android/sdk/TestCaseBase.java +++ b/apptentive/src/testCommon/java/com/apptentive/android/sdk/TestCaseBase.java @@ -8,6 +8,8 @@ import android.os.SystemClock; +import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.debug.AssertImp; import com.apptentive.android.sdk.util.StringUtils; import com.apptentive.android.sdk.util.threading.MockDispatchQueue; @@ -21,12 +23,33 @@ public class TestCaseBase { private List result = new ArrayList<>(); private MockDispatchQueue dispatchQueue; + //region Setup + + protected void setUp() { + Assert.setImp(new AssertImp() { + @Override + public void assertFailed(String message) { + throw new AssertionError(message); + } + }); + } + + protected void tearDown() { + Assert.setImp(null); + } + + //endregion + //region Results protected void addResult(String str) { result.add(str); } + protected void addResult(String format, Object... params) { + result.add(StringUtils.format(format, params)); + } + protected void assertResult(String... expected) { // Make sure the expected and result sets contain the same number of items if (expected.length != result.size()) { @@ -61,4 +84,4 @@ protected void sleep(long millis) { } //endregion -} +} \ No newline at end of file diff --git a/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpJsonRequest.java b/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpJsonRequest.java new file mode 100644 index 000000000..9d6cd51cf --- /dev/null +++ b/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpJsonRequest.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.network; + +import org.json.JSONObject; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; + +public class MockHttpJsonRequest extends HttpJsonRequest { + + private final MockHttpURLConnection connection; + + public MockHttpJsonRequest(String name, JSONObject requestObject) { + super("https://abc.com", requestObject); + connection = new MockHttpURLConnection(); + connection.setMockResponseCode(200); + setName(name); + setMethod(HttpRequestMethod.POST); + } + + @Override + protected HttpURLConnection openConnection(URL url) throws IOException { + return connection; + } + + @Override + public String toString() { + return getName(); + } + + public MockHttpJsonRequest setMockResponseData(JSONObject responseData) { + return setMockResponseData(responseData.toString()); + } + + public MockHttpJsonRequest setMockResponseData(String responseData) { + connection.setMockResponseData(responseData); + return this; + } + + @Override + protected boolean isNetworkConnectionPresent() { + return true; + } +} diff --git a/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpRequest.java b/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpRequest.java new file mode 100644 index 000000000..dbca797e9 --- /dev/null +++ b/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpRequest.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.network; + +import com.apptentive.android.sdk.network.MockHttpURLConnection.ResponseHandler; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; + +public class MockHttpRequest extends HttpRequest { + + private final MockHttpURLConnection connection; + + public MockHttpRequest(String name) { + super("https://abc.com"); + connection = new MockHttpURLConnection(); + connection.setMockResponseCode(200); + setName(name); + setRetryPolicy(new HttpRequestRetryPolicyDefault() { + @Override + public boolean shouldRetryRequest(int responseCode, int retryAttempt) { + return false; // do not retry by default + } + }); + } + + @Override + protected HttpURLConnection openConnection(URL url) throws IOException { + return connection; + } + + public MockHttpRequest setThrowsExceptionOnConnect(boolean throwsExceptionOnConnect) { + connection.throwsExceptionOnConnect = throwsExceptionOnConnect; + return this; + } + + public MockHttpRequest setThrowsExceptionOnDisconnect(boolean throwsExceptionOnDisconnect) { + connection.throwsExceptionOnDisconnect = throwsExceptionOnDisconnect; + return this; + } + + public MockHttpRequest setMockResponseHandler(ResponseHandler handler) { + connection.setMockResponseHandler(handler); + return this; + } + + public MockHttpRequest setMockResponseCode(int mockResponseCode) { + connection.setMockResponseCode(mockResponseCode); + return this; + } + + public MockHttpRequest setResponseData(String responseData) { + connection.setMockResponseData(responseData); + return this; + } + + @Override + protected boolean isNetworkConnectionPresent() { + return true; + } + + @Override + public String toString() { + return getName(); + } +} diff --git a/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpURLConnection.java b/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpURLConnection.java new file mode 100644 index 000000000..054c70dd1 --- /dev/null +++ b/apptentive/src/testCommon/java/com/apptentive/android/sdk/network/MockHttpURLConnection.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.sdk.network; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.util.HashMap; +import java.util.Map; + +public class MockHttpURLConnection extends HttpURLConnection { + private static final Map statusLookup; + + static { + statusLookup = new HashMap<>(); + statusLookup.put(200, "OK"); + statusLookup.put(204, "No Content"); + statusLookup.put(400, "Bad Request"); + statusLookup.put(500, "Internal Server Error"); + } + + boolean throwsExceptionOnConnect; + boolean throwsExceptionOnDisconnect; + + private ResponseHandler responseHandler = new DefaultResponseHandler(200, "", ""); // HTTP OK by default + private int lastResponseCode; // remember the last returned HTTP response code to properly resolve response message + + protected MockHttpURLConnection() { + super(null); + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() throws IOException { + connected = true; + } + + @Override + public void disconnect() { + connected = false; + if (throwsExceptionOnDisconnect) { + throw new RuntimeException("Disconnection error"); + } + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(responseHandler.getResponseData().getBytes("UTF-8")); + } + + @Override + public InputStream getErrorStream() { + try { + return new ByteArrayInputStream(responseHandler.getErrorData().getBytes("UTF-8")); + } catch (UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + @Override + public OutputStream getOutputStream() throws IOException { + return new ByteArrayOutputStream(); + } + + @Override + public int getResponseCode() throws IOException { + if (throwsExceptionOnConnect) { + throw new IOException("Connection error"); + } + lastResponseCode = responseHandler.getResponseCode(); + return lastResponseCode; + } + + @Override + public String getResponseMessage() throws IOException { + return statusLookup.get(lastResponseCode); + } + + @Override + public void setRequestMethod(String method) throws ProtocolException { + } + + public void setMockResponseCode(int mockResponseCode) { + ((DefaultResponseHandler) responseHandler).setResponseCode(mockResponseCode); + } + + public void setMockResponseData(String responseData) { + ((DefaultResponseHandler) responseHandler).setResponseData(responseData); + } + + public void setMockResponseHandler(ResponseHandler handler) { + responseHandler = handler; + } + + public interface ResponseHandler { + int getResponseCode(); + String getResponseData(); + String getErrorData(); + } + + public static class DefaultResponseHandler implements ResponseHandler { + private int responseCode; + private String responseData; + private String errorData; + + public DefaultResponseHandler() { + this(200, "", ""); + } + + public DefaultResponseHandler(int responseCode, String responseData, String errorData) { + this.responseCode = responseCode; + this.responseData = responseData; + this.errorData = errorData; + } + + public DefaultResponseHandler setResponseCode(int responseCode) { + this.responseCode = responseCode; + return this; + } + + @Override + public int getResponseCode() { + return responseCode; + } + + public DefaultResponseHandler setResponseData(String responseData) { + this.responseData = responseData; + return this; + } + + @Override + public String getResponseData() { + return responseData; + } + + public DefaultResponseHandler setErrorData(String errorData) { + this.errorData = errorData; + return this; + } + + @Override + public String getErrorData() { + return errorData; + } + } +} diff --git a/build.gradle b/build.gradle index 21ec059b0..cb6149a15 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,15 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.2.3' + classpath 'com.android.tools.build:gradle:2.3.3' + } +} + +allprojects { + repositories { + jcenter() + maven { + url "https://maven.google.com" + } } } \ No newline at end of file diff --git a/docs/APIChanges.md b/docs/APIChanges.md index 836ac7651..59c1d0083 100644 --- a/docs/APIChanges.md +++ b/docs/APIChanges.md @@ -1,5 +1,24 @@ This document tracks changes to the API between versions. +# 4.0.0 + +Read the [Migrating to 4.0.0](migrating_to_4.0.0.md) guide. + +| Added Methods | +| ------------- | +| public static void login(String token, LoginCallback callback) | +| public static void logout() | + +| Modified Methods | +| ---------------- | +| public static void register(Application application, String apptentiveKey, String apptentiveSignature) | + +| Added Classes | +| ------------- | +| public interface Apptentive.LoginCallback | +| public interface Apptentive.AuthenticationFailedListener | +| public enum Apptentive.AuthenticationFailedReason | + # 3.4.1 | Added Methods| @@ -35,13 +54,13 @@ Read the [Migrating to 3.3.0](migrating_to_3.3.0.md) guide. | public static void onStart(android.app.Activity activity) | | public static void onStop(android.app.Activity activity) | -| Removed Class | +| Removed Classes | | ------------------ | | ApptentiveActivity.java | | ApptentiveListActivity.java | | ViewActivity.java | -| Added Class | +| Added Classes | | ------------------ | | ApptentiveViewActivity.java | diff --git a/docs/migrating_to_4.0.0.md b/docs/migrating_to_4.0.0.md new file mode 100644 index 000000000..f207d0d3b --- /dev/null +++ b/docs/migrating_to_4.0.0.md @@ -0,0 +1,34 @@ +# Migrating to 4.0.0 + +Version 4.0.0 of the Apptentive Android SDK has new APIs for customer login, as well as some changed APIs for SDK registration. If you were using any previous version of the Apptentive SDK in your app, you will need to follow one or more of the applicable steps below. + +## Major changes + +### Registration Has Changed + +* Registration now requires two pieces of information instead of one. Where before you needed to copy your API Key from the Apptentive website, now you need to copy the Apptentive App Key and Apptentive App Signature. You can find both of those in the [API & Development](https://be.apptentive.com/apps/current/settings/api) page. + +### Push Notification Changes + +* If you are not using the new Customer Login feature, you don't strictly have to follow this step, but it is a good idea in case you add it in the future. +* When you reply to your customers from the Apptentive website, we will send a push, if configured. If the customer for which the push is intended is not logged in, then the push should not be displayed. This small change means that you need to check whether the push is and Apptentive push, or should be handled by your existing push code, and then check where it can be displayed. See the [Push Notifications](https://learn.apptentive.com/knowledge-base/android-integration-reference/#push-notifications) documentation for code that demonstrates this change for the push provider you are using. + +## How to migrate + +The migration process should take 5-10 minutes to complete. + +1. Replace the call to `Apptentive.register(Application yourApp, String apiKey)` with a call to `Apptentive.register(Application yourApp, String apptentiveKey, String apptentiveSignature)`. +2. If you were calling `Apptentive.register(Application yourApp)`, and specifying the API Key in your manifest, you can continue to do so in version 4.0.0, you will just need to remove the old manifest element and add two new elements for the Apptentive App Key and Apptentive App Signature. + + Remove: + + `` + + Add: + + `` + + `` + +3. Make sure to check whether a push came from Apptentive, and handle it yourself if `Apptentive.isApptentivePushNotification()` returns `false`. + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 50e3929ff..42156b3bf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Sep 21 15:22:15 PDT 2016 +#Tue Mar 28 10:38:53 PDT 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-3.3-all.zip diff --git a/samples/apptentive-example/build.gradle b/samples/apptentive-example/build.gradle index d064ff50c..0461b7ee2 100644 --- a/samples/apptentive-example/build.gradle +++ b/samples/apptentive-example/build.gradle @@ -3,7 +3,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.google.gms:google-services:2.1.0' + classpath 'com.google.gms:google-services:3.0.0' } } @@ -14,20 +14,17 @@ repositories { } dependencies { - compile project(':apptentive') - compile 'com.android.support:support-v4:23.3.0' - compile 'com.android.support:appcompat-v7:23.3.0' - compile 'com.android.support:cardview-v7:23.3.0' - compile 'com.google.android.gms:play-services-gcm:8.4.0' + compile 'com.apptentive:apptentive-android:3.4.1' + compile 'com.google.firebase:firebase-messaging:10.2.1' } android { - compileSdkVersion 23 - buildToolsVersion '23.0.3' + compileSdkVersion 25 + buildToolsVersion '25.0.3' defaultConfig { minSdkVersion 14 - targetSdkVersion 23 + targetSdkVersion 25 versionCode 1 versionName "1.0" multiDexEnabled true diff --git a/samples/apptentive-example/google-services.json b/samples/apptentive-example/google-services.json index 327ee48f5..1d392aa22 100644 --- a/samples/apptentive-example/google-services.json +++ b/samples/apptentive-example/google-services.json @@ -1,39 +1,46 @@ { "project_info": { - "project_id": "apptentive-example-app", "project_number": "864112465884", - "name": "Apptentive Example App" + "firebase_url": "https://apptentive-example-app.firebaseio.com", + "project_id": "apptentive-example-app", + "storage_bucket": "apptentive-example-app.appspot.com" }, "client": [ { "client_info": { - "client_id": "android:com.apptentive.android.example", - "client_type": 1, + "mobilesdk_app_id": "1:864112465884:android:f009d8e2b2851a1c", "android_client_info": { "package_name": "com.apptentive.android.example" } }, - "oauth_client": [], + "oauth_client": [ + { + "client_id": "864112465884-vdhbg3arakm2n71a9rhuuui843dq0q2j.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "864112465884-5d35u6lus8uqmgieh30aeg645ofkr5ni.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBki4xmuidZeU9UjTJscOKIysSGUWGb-Ps" + } + ], "services": { "analytics_service": { "status": 1 }, - "cloud_messaging_service": { - "status": 2, - "apns_config": [] - }, "appinvite_service": { "status": 1, "other_platform_oauth_client": [] }, - "google_signin_service": { - "status": 1 - }, "ads_service": { - "status": 1 + "status": 2 } } } ], - "ARTIFACT_VERSION": "1" + "configuration_version": "1" } \ No newline at end of file diff --git a/samples/apptentive-example/src/main/AndroidManifest.xml b/samples/apptentive-example/src/main/AndroidManifest.xml index d3c84f1d9..9245f8732 100755 --- a/samples/apptentive-example/src/main/AndroidManifest.xml +++ b/samples/apptentive-example/src/main/AndroidManifest.xml @@ -1,23 +1,6 @@ - - - - - - - - - - - - - - - - - - + + - - - - - - - - - + - + + - + - - - \ No newline at end of file diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleActivity.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleActivity.java index dabe620ed..1751a2190 100755 --- a/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleActivity.java +++ b/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleActivity.java @@ -1,17 +1,15 @@ /* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ package com.apptentive.android.example; -import android.content.Intent; import android.os.Bundle; import android.support.v7.app.AppCompatActivity; import android.view.View; -import com.apptentive.android.example.push.RegistrationIntentService; import com.apptentive.android.sdk.Apptentive; /** @@ -23,19 +21,6 @@ public class ExampleActivity extends AppCompatActivity { public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); - - // GCM: Start IntentService to register this application. - Intent intent = new Intent(this, RegistrationIntentService.class); - startService(intent); - } - - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - // Only engage if this window is gaining focus. - if (hasFocus) { - Apptentive.handleOpenedPushNotification(this); - } } /** @@ -44,4 +29,12 @@ public void onWindowFocusChanged(boolean hasFocus) { public void onMessageCenterButtonPressed(@SuppressWarnings("unused") View view) { Apptentive.showMessageCenter(this); } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (true) { + Apptentive.engage(this, "main_activity_focused"); + } + } } diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleApplication.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleApplication.java index c1955369e..1784bd29c 100644 --- a/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleApplication.java +++ b/samples/apptentive-example/src/main/java/com/apptentive/android/example/ExampleApplication.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. * Please refer to the LICENSE file for the terms and conditions * under which redistribution and use of this file is permitted. */ @@ -11,6 +11,8 @@ import com.apptentive.android.sdk.Apptentive; public class ExampleApplication extends Application { + public static final String TAG = "ApptentiveExample"; + @Override public void onCreate() { super.onCreate(); diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyFirebaseInstanceIdService.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyFirebaseInstanceIdService.java new file mode 100644 index 000000000..7575739d0 --- /dev/null +++ b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyFirebaseInstanceIdService.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.example.push; + +import android.util.Log; + +import com.apptentive.android.example.ExampleApplication; +import com.apptentive.android.sdk.Apptentive; +import com.google.firebase.iid.FirebaseInstanceId; +import com.google.firebase.iid.FirebaseInstanceIdService; + +public class MyFirebaseInstanceIdService extends FirebaseInstanceIdService { + @Override + public void onTokenRefresh() { + String token = FirebaseInstanceId.getInstance().getToken(); + Log.e(ExampleApplication.TAG, "Refreshed InstanceId token: " + token); + Apptentive.setPushNotificationIntegration(Apptentive.PUSH_PROVIDER_APPTENTIVE, token); + } +} diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyFirebaseMessagingService.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyFirebaseMessagingService.java new file mode 100644 index 000000000..c70515979 --- /dev/null +++ b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyFirebaseMessagingService.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2017, Apptentive, Inc. All Rights Reserved. + * Please refer to the LICENSE file for the terms and conditions + * under which redistribution and use of this file is permitted. + */ + +package com.apptentive.android.example.push; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.media.RingtoneManager; +import android.net.Uri; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +import com.apptentive.android.example.ExampleApplication; +import com.apptentive.android.example.R; +import com.apptentive.android.sdk.Apptentive; +import com.apptentive.android.sdk.ApptentiveLog; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +import java.util.Map; + +public class MyFirebaseMessagingService extends FirebaseMessagingService { + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + Log.e(ExampleApplication.TAG, "onMessageReceived()"); + logPushBundle(remoteMessage); + Map data = remoteMessage.getData(); + + if (Apptentive.isApptentivePushNotification(data)) { + PendingIntent pendingIntent = Apptentive.buildPendingIntentFromPushNotification(data); + if (pendingIntent != null) { + String title = Apptentive.getTitleFromApptentivePush(data); + String body = Apptentive.getBodyFromApptentivePush(data); + + + ApptentiveLog.e("Title: " + title); + ApptentiveLog.e("Body: " + body); + + Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); + NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.notification) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setContentIntent(pendingIntent); + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.notify(0, notificationBuilder.build()); + } else { + // This push is from Apptentive, but not for the active conversation, so we can't safely display it. + } + } else { + // This push did not come from Apptentive, so handle it as your own push. + } + } + + private static void logPushBundle(RemoteMessage remoteMessage) { + Map data = remoteMessage.getData(); + Log.e(ExampleApplication.TAG, "Push Data:"); + for (String key : data.keySet()) { + String value = data.get(key); + Log.e(ExampleApplication.TAG, " " + key + " : " + value); + } + Log.e(ExampleApplication.TAG, data.get("title") + ": " + data.get("body")); + } +} diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyGcmListenerService.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyGcmListenerService.java deleted file mode 100644 index 1f3553ad5..000000000 --- a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyGcmListenerService.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.example.push; - -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.content.Context; -import android.content.Intent; -import android.media.RingtoneManager; -import android.net.Uri; -import android.os.Bundle; -import android.support.v4.app.NotificationCompat; - -import com.apptentive.android.example.ExampleActivity; -import com.apptentive.android.example.R; -import com.apptentive.android.sdk.Apptentive; -import com.apptentive.android.sdk.ApptentiveLog; -import com.google.android.gms.gcm.GcmListenerService; - -/** - * @author Sky Kelsey - */ -public class MyGcmListenerService extends GcmListenerService { - @Override - public void onMessageReceived(String from, Bundle data) { - String title = data.getString("gcm.notification.title"); - String body = data.getString("gcm.notification.body"); - ApptentiveLog.e("From: " + from); - ApptentiveLog.e("Title: " + title); - ApptentiveLog.e("Body: " + body); - - Intent intent = new Intent(this, ExampleActivity.class); - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - - Apptentive.setPendingPushNotification(data); - - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent, PendingIntent.FLAG_ONE_SHOT); - - Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this) - .setSmallIcon(R.drawable.notification) - .setContentTitle(title) - .setContentText(body) - .setAutoCancel(true) - .setSound(defaultSoundUri) - .setContentIntent(pendingIntent); - - NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - - notificationManager.notify(0 /* ID of notification */, notificationBuilder.build()); - } -} diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyInstanceIdListenerService.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyInstanceIdListenerService.java deleted file mode 100644 index c666513dd..000000000 --- a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/MyInstanceIdListenerService.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.example.push; - -import android.content.Intent; - -import com.google.android.gms.iid.InstanceIDListenerService; - -/** - * @author Sky Kelsey - */ -public class MyInstanceIdListenerService extends InstanceIDListenerService { - @Override - public void onTokenRefresh() { - Intent intent = new Intent(this, RegistrationIntentService.class); - startService(intent); - } -} diff --git a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/RegistrationIntentService.java b/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/RegistrationIntentService.java deleted file mode 100644 index 6a1008fad..000000000 --- a/samples/apptentive-example/src/main/java/com/apptentive/android/example/push/RegistrationIntentService.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (c) 2015, Apptentive, Inc. All Rights Reserved. - * Please refer to the LICENSE file for the terms and conditions - * under which redistribution and use of this file is permitted. - */ - -package com.apptentive.android.example.push; - -import android.app.IntentService; -import android.content.Intent; - -import com.apptentive.android.example.R; -import com.apptentive.android.sdk.Apptentive; -import com.apptentive.android.sdk.ApptentiveLog; -import com.google.android.gms.gcm.GoogleCloudMessaging; -import com.google.android.gms.iid.InstanceID; - -import java.io.IOException; - -/** - * @author Sky Kelsey - */ -public class RegistrationIntentService extends IntentService { - - private static final String TAG = "RegistrationIntentService"; - - public RegistrationIntentService() { - super(TAG); - } - - @Override - protected void onHandleIntent(Intent intent) { - InstanceID instanceID = InstanceID.getInstance(this); - try { - String token = instanceID.getToken(getString(R.string.gcm_defaultSenderId), GoogleCloudMessaging.INSTANCE_ID_SCOPE, null); - Apptentive.setPushNotificationIntegration(Apptentive.PUSH_PROVIDER_APPTENTIVE, token); - } catch (IOException e) { - ApptentiveLog.e("Unable to get instanceId token.", e); - } - } -} diff --git a/tests/test-app/build.gradle b/tests/test-app/build.gradle index 14148ca99..ac5d1cc79 100644 --- a/tests/test-app/build.gradle +++ b/tests/test-app/build.gradle @@ -5,13 +5,13 @@ repositories { } android { - compileSdkVersion 23 + compileSdkVersion 25 - buildToolsVersion '23.0.3' + buildToolsVersion '25.0.3' defaultConfig { minSdkVersion 14 - targetSdkVersion 23 + targetSdkVersion 25 versionCode 4 versionName "2.0" } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/ApptentiveTestCaseBase.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/ApptentiveTestCaseBase.java index f12d52e95..4c2631fbd 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/ApptentiveTestCaseBase.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/ApptentiveTestCaseBase.java @@ -69,7 +69,7 @@ public static String loadRawTextResourceAsString(Context context, int resourceId } return builder.toString(); } catch (IOException e) { - ApptentiveLog.e("Error reading from raw resource with ID \"%d\"", e, resourceId); + ApptentiveLog.e(e, "Error reading from raw resource with ID \"%d\"", resourceId); } finally { Util.ensureClosed(reader); } @@ -94,7 +94,7 @@ public static String loadTextAssetAsString(Context context, String path) { } return builder.toString(); } catch (IOException e) { - ApptentiveLog.e("Error reading from file \"%s\"", e, path); + ApptentiveLog.e(e, "Error reading from file \"%s\"", path); } finally { Util.ensureClosed(reader); } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/DateTimeComparisonTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/DateTimeComparisonTest.java index 84ba866b9..97842581d 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/DateTimeComparisonTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/DateTimeComparisonTest.java @@ -59,7 +59,7 @@ public void dateTimeComparison() { assertEquals(String.format("Comparison of [\"%s\" %s \"%s\"] failed", left, operator, right), expected, actual); } } catch (JSONException e) { - ApptentiveLog.e("Error loading experiment results.", e); + ApptentiveLog.e(e, "Error loading experiment results."); } } } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/EventTests.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/EventTests.java index 056da83a7..9a8298c4f 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/EventTests.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/EventTests.java @@ -59,7 +59,7 @@ public void extendedDataEvents() { CommerceExtendedData.Item item = new CommerceExtendedData.Item(22222222, "Item Name", "Category", 20, 5.0d, "USD"); commerce.addItem(item); } catch (JSONException e) { - ApptentiveLog.e("Error: ", e); + ApptentiveLog.e(e, "Error: "); } assertNotNull(commerce); diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/VersionComparisonTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/VersionComparisonTest.java index 109aa2fa1..5cac5b71f 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/VersionComparisonTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/model/VersionComparisonTest.java @@ -76,7 +76,7 @@ public void basicVersionComparison() { assertEquals(String.format("Comparison of [\"%s\" %s \"%s\"] failed", left, operator, right), expected, actual); } } catch (JSONException e) { - ApptentiveLog.e("Error loading experiment results.", e); + ApptentiveLog.e(e, "Error loading experiment results."); } } } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/CriteriaParsingTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/CriteriaParsingTest.java index eb80e98b4..24137d3ce 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/CriteriaParsingTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/CriteriaParsingTest.java @@ -35,7 +35,7 @@ public void predicateParsing() throws JSONException { Clause criteria = ClauseParser.parse(json); assertNotNull("Criteria was null, but it shouldn't be.", criteria); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNotNull(e); } } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/EventTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/EventTest.java index d3ee2f828..f2a4a0210 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/EventTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/EventTest.java @@ -49,7 +49,7 @@ public void eventLabelCreation() { assertTrue(result.equals(expected)); } } catch (IOException e) { - ApptentiveLog.e("Error reading asset.", e); + ApptentiveLog.e(e, "Error reading asset."); throw new RuntimeException(e); } finally { Util.ensureClosed(reader); diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/InteractionTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/InteractionTest.java index f3a9342ba..d9fb57af6 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/InteractionTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/InteractionTest.java @@ -169,7 +169,7 @@ public void criteriaApplicationVersionCode() { InteractionCriteria criteria = new InteractionCriteria(json); assertTrue(criteria.isMet()); } catch (Exception e) { - ApptentiveLog.e("Error running test.", e); + ApptentiveLog.e(e, "Error running test."); assertNull(e); } } @@ -187,7 +187,7 @@ public void criteriaApplicationVersionName() { InteractionCriteria criteria = new InteractionCriteria(json); assertTrue(criteria.isMet()); } catch (Exception e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } } @@ -204,7 +204,7 @@ public void criteriaApplicationDebug() { InteractionCriteria criteria = new InteractionCriteria(json); assertTrue(criteria.isMet()); } catch (Exception e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/SurveyInteractionTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/SurveyInteractionTest.java index 3642f7236..11d8a348d 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/SurveyInteractionTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/SurveyInteractionTest.java @@ -32,7 +32,7 @@ public void surveyParsing() { try { survey = new SurveyInteraction(json); } catch (Exception e) { - ApptentiveLog.e("Error loading survey.", e); + ApptentiveLog.e(e, "Error loading survey."); } assertNotNull(survey); } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CodePointAndInteractionStoreTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CodePointAndInteractionStoreTest.java index 1f20ae712..af90d927f 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CodePointAndInteractionStoreTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CodePointAndInteractionStoreTest.java @@ -133,7 +133,7 @@ public void codePointInvokesTotal() { codePointStore.storeCodePointForCurrentAppVersion("test.code.point"); assertFalse(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); @@ -258,7 +258,7 @@ public void codePointInvokesVersionCode() { assertFalse(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); @@ -383,7 +383,7 @@ public void codePointInvokesVersionName() { assertFalse(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); @@ -461,7 +461,7 @@ public void codePointLastInvokedAt() { assertTrue(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); @@ -577,7 +577,7 @@ public void interactionInvokesTotal() { assertFalse(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CornerCases.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CornerCases.java index 2c1d201a6..a46aa474d 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CornerCases.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/CornerCases.java @@ -42,7 +42,7 @@ public void ornerCasesThatShouldBeTrue() throws JSONException { boolean result = criteria.evaluate(); assertTrue(result); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); @@ -62,7 +62,7 @@ public void cornerCasesThatShouldBeFalse() throws JSONException { boolean result = criteria.evaluate(); assertTrue(result); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } ApptentiveLog.e("Finished test."); diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/DefaultValues.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/DefaultValues.java index 9068ee2cc..5af29b1bd 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/DefaultValues.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/DefaultValues.java @@ -39,7 +39,7 @@ public void defaultValues() throws JSONException { boolean result = criteria.evaluate(); assertTrue(result); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/OperatorTests.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/OperatorTests.java index 680d625d3..04905ebd7 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/OperatorTests.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/OperatorTests.java @@ -115,7 +115,7 @@ private void doTest(String testFile) { InteractionCriteria criteria = new InteractionCriteria(json); assertTrue(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } } diff --git a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/WhitespaceTrimmingTest.java b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/WhitespaceTrimmingTest.java index b159dbb1c..8cc70a6a9 100644 --- a/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/WhitespaceTrimmingTest.java +++ b/tests/test-app/src/androidTest/java/com/apptentive/android/sdk/tests/module/engagement/criteria/WhitespaceTrimmingTest.java @@ -42,7 +42,7 @@ private void doTest(String testFile) { InteractionCriteria criteria = new InteractionCriteria(json); assertTrue(criteria.isMet()); } catch (JSONException e) { - ApptentiveLog.e("Error parsing test JSON.", e); + ApptentiveLog.e(e, "Error parsing test JSON."); assertNull(e); } }