From 2d39b8d2df31524f33f3591d51ad535faeb2de4f Mon Sep 17 00:00:00 2001 From: Alex Lementuev Date: Thu, 30 Aug 2018 16:55:28 -0700 Subject: [PATCH] Apptentive Android SDK 5.3.0 --- .travis.yml | 3 + CHANGELOG.md | 6 + README.md | 2 +- .../conversation/FileMessageStoreTest.java | 39 +- .../EncryptedPayloadSenderTest.java | 22 +- .../android/sdk/encryption/EncryptorTest.java | 19 +- .../sdk/encryption/SecurityManagerTest.java | 54 +++ .../storage/ApptentiveDatabaseHelperTest.java | 3 +- .../android/sdk/ApptentiveInternal.java | 7 +- .../android/sdk/ApptentiveLogTag.java | 3 +- .../sdk/comm/ApptentiveHttpClient.java | 2 +- .../sdk/conversation/Conversation.java | 105 ++-- .../ConversationLoadException.java | 7 + .../sdk/conversation/ConversationManager.java | 288 ++++++----- .../conversation/ConversationMetadata.java | 4 +- .../ConversationMetadataItem.java | 97 ++-- .../sdk/conversation/FileMessageStore.java | 125 +++-- .../android/sdk/encryption/EncryptionKey.java | 65 +++ .../android/sdk/encryption/Encryptor.java | 149 ++++-- .../sdk/encryption/SecurityManager.java | 130 +++++ .../sdk/encryption/resolvers/KeyResolver.java | 11 + .../encryption/resolvers/KeyResolver18.java | 234 +++++++++ .../encryption/resolvers/KeyResolver23.java | 75 +++ .../encryption/resolvers/KeyResolver26.java | 4 + .../resolvers/KeyResolverFactory.java | 24 + .../encryption/resolvers/KeyResolverNoOp.java | 13 + .../android/sdk/model/CompoundMessage.java | 18 +- .../android/sdk/model/JsonPayload.java | 12 +- .../apptentive/android/sdk/model/Payload.java | 41 +- .../android/sdk/model/PayloadData.java | 10 +- .../serialization/ObjectSerialization.java | 46 ++ .../sdk/storage/ApptentiveDatabaseHelper.java | 451 +++++------------- .../sdk/storage/ApptentiveTaskManager.java | 5 +- .../android/sdk/storage/DatabaseMigrator.java | 79 +++ .../sdk/storage/DatabaseMigratorV1.java | 29 ++ .../sdk/storage/DatabaseMigratorV2.java | 179 +++++++ .../sdk/storage/DatabaseMigratorV3.java | 132 +++++ .../sdk/storage/EncryptedFileSerializer.java | 14 +- .../android/sdk/util/Constants.java | 5 +- .../android/sdk/util/ObjectUtils.java | 6 +- .../android/sdk/util/RuntimeUtils.java | 3 +- .../android/sdk/util/StringUtils.java | 42 +- .../com/apptentive/android/sdk/util/Util.java | 31 +- .../apptentive/android/sdk/util/UtilTest.java | 19 + build.gradle | 2 +- samples/apptentive-example/build.gradle | 6 +- tests/test-app/build.gradle | 2 +- 47 files changed, 1873 insertions(+), 750 deletions(-) rename apptentive/src/androidTest/java/com/apptentive/android/sdk/{storage => encryption}/EncryptedPayloadSenderTest.java (66%) create mode 100644 apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/SecurityManagerTest.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationLoadException.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/EncryptionKey.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/SecurityManager.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver18.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver23.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver26.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverFactory.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverNoOp.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigrator.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV1.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV2.java create mode 100644 apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV3.java create mode 100644 apptentive/src/test/java/com/apptentive/android/sdk/util/UtilTest.java diff --git a/.travis.yml b/.travis.yml index 73df64e76..0fbe64233 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,8 +21,10 @@ android: - platform-tools - tools # not a mistakenly duplicated line: used above api 25.x - build-tools-27.0.3 + - build-tools-28.0.2 - android-19 - android-27 + - android-28 - extra-google-google_play_services - extra-google-m2repository - extra-android-m2repository @@ -30,6 +32,7 @@ android: - sys-img-armeabi-v7a-android-19 before_install: - yes | sdkmanager "platforms;android-27" +- yes | sdkmanager "platforms;android-28" install: true before_script: - echo no | android create avd --force -n test -t android-19 --abi armeabi-v7a diff --git a/CHANGELOG.md b/CHANGELOG.md index 465e4ba65..19bca4b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 2018-08-30 - v5.3.0 + +#### Improvements + +* Improved SDK security with encryption and KeyStore. + # 2018-08-23 - v5.2.0 #### Improvements diff --git a/README.md b/README.md index 4ef00440f..f387d7097 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|5.2.0|aar) +##### Binary releases are hosted for Maven [here](http://search.maven.org/#artifactdetails|com.apptentive|apptentive-android|5.3.0|aar) #### Reporting Bugs 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 index 2242fc3a6..e5ceb10f6 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/conversation/FileMessageStoreTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/conversation/FileMessageStoreTest.java @@ -10,6 +10,7 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.TestCaseBase; +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.model.ApptentiveMessage; import com.apptentive.android.sdk.model.CompoundMessage; import com.apptentive.android.sdk.util.StringUtils; @@ -37,11 +38,13 @@ public class FileMessageStoreTest extends TestCaseBase { @Rule public TemporaryFolder tempFolder = new TemporaryFolder(); + private EncryptionKey encryptionKey; @Before public void setUp() throws Exception { super.setUp(); ApptentiveInternal.setInstance(new ApptentiveInternal(InstrumentationRegistry.getTargetContext())); + encryptionKey = EncryptionKey.NULL; // FIXME: generate key } @After @@ -55,13 +58,13 @@ public void testAddingAndLoadingMessages() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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); + store = new FileMessageStore(file, encryptionKey); addResult(store.getAllMessages()); assertResult( @@ -70,7 +73,7 @@ public void testAddingAndLoadingMessages() throws Exception { "{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); // reload the store again and add another message - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); store.addOrUpdateMessages(createMessage("4", State.sent, UNREAD, 40.0)); addResult(store.getAllMessages()); @@ -87,13 +90,13 @@ public void updateMessage() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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 = new FileMessageStore(file, encryptionKey); store.updateMessage(createMessage("2", State.saved, READ, 40.0)); addResult(store.getAllMessages()); @@ -104,7 +107,7 @@ public void updateMessage() throws Exception { // reload the store and check the stored messages - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); addResult(store.getAllMessages()); assertResult( @@ -118,7 +121,7 @@ public void getLastReceivedMessageId() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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")); @@ -127,7 +130,7 @@ public void getLastReceivedMessageId() throws Exception { assertEquals("222", store.getLastReceivedMessageId()); // reload the store and check again - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); assertEquals("222", store.getLastReceivedMessageId()); } @@ -136,7 +139,7 @@ public void getUnreadMessageCount() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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)); @@ -145,7 +148,7 @@ public void getUnreadMessageCount() throws Exception { assertEquals(2, store.getUnreadMessageCount()); // reload store and check saved messages - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); assertEquals(2, store.getUnreadMessageCount()); } @@ -154,7 +157,7 @@ public void deleteAllMessages() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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)); @@ -168,7 +171,7 @@ public void deleteAllMessages() throws Exception { assertResult(); // reload the store and check for messages - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); addResult(store.getAllMessages()); assertResult(); } @@ -178,7 +181,7 @@ public void deleteAllMessagesAfterReload() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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)); @@ -188,7 +191,7 @@ public void deleteAllMessagesAfterReload() throws Exception { store.deleteAllMessages(); // reload the store and check for messages - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); addResult(store.getAllMessages()); assertResult(); } @@ -198,7 +201,7 @@ public void deleteMessage() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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)); @@ -219,7 +222,7 @@ public void deleteMessageAndReload() throws Exception { File file = getTempFile(); // create a few messages and add them to the store - FileMessageStore store = new FileMessageStore(file); + FileMessageStore store = new FileMessageStore(file, encryptionKey); 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)); @@ -229,7 +232,7 @@ public void deleteMessageAndReload() throws Exception { store.deleteMessage("4"); // reload store - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); addResult(store.getAllMessages()); assertResult( @@ -243,7 +246,7 @@ public void deleteMessageAndReload() throws Exception { assertResult("{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); // reload store - store = new FileMessageStore(file); + store = new FileMessageStore(file, encryptionKey); addResult(store.getAllMessages()); assertResult("{'nonce':'3','client_created_at':'30','state':'saved','read':'true'}"); diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/EncryptedPayloadSenderTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptedPayloadSenderTest.java similarity index 66% rename from apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/EncryptedPayloadSenderTest.java rename to apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptedPayloadSenderTest.java index 1d4fc4707..249b426a4 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/EncryptedPayloadSenderTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptedPayloadSenderTest.java @@ -4,10 +4,9 @@ * under which redistribution and use of this file is permitted. */ -package com.apptentive.android.sdk.storage; +package com.apptentive.android.sdk.encryption; import com.apptentive.android.sdk.TestCaseBase; -import com.apptentive.android.sdk.encryption.Encryptor; import com.apptentive.android.sdk.model.EventPayload; import org.json.JSONObject; @@ -25,21 +24,16 @@ public class EncryptedPayloadSenderTest extends TestCaseBase { @Test public void testEncryptedPayload() throws Exception { + EncryptionKey encryptionKey = new EncryptionKey(ENCRYPTION_KEY); + final EventPayload original = new EventPayload(EVENT_LABEL, "trigger"); original.setToken(AUTH_TOKEN); - original.setEncryptionKey(ENCRYPTION_KEY); + original.setEncryptionKey(encryptionKey); 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()); - } + byte[] plainText = Encryptor.decrypt(encryptionKey, cipherText); + JSONObject result = new JSONObject(new String(plainText)); + String label = result.getJSONObject("event").getString("label"); + assertEquals(label, EVENT_LABEL); } } \ 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 index 7cb9d1789..d94f9aa36 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptorTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/EncryptorTest.java @@ -6,7 +6,6 @@ package com.apptentive.android.sdk.encryption; -import org.junit.Before; import org.junit.Test; import java.util.Arrays; @@ -15,34 +14,30 @@ import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; +import static com.apptentive.android.sdk.encryption.EncryptionKey.DEFAULT_TRANSFORMATION; 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 { + @Test + public void testRoundTripEncryption() 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]; + byte[] 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); + EncryptionKey key = new EncryptionKey(secretKey, DEFAULT_TRANSFORMATION); + byte[] cipherText = Encryptor.encrypt(key, testData); assertNotNull(cipherText); - byte[] plainText = encryptor.decrypt(cipherText); + byte[] plainText = Encryptor.decrypt(key, cipherText); long stop = System.currentTimeMillis(); System.out.println(String.format("Round trip encryption took: %dms", stop - start)); assertNotNull(plainText); diff --git a/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/SecurityManagerTest.java b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/SecurityManagerTest.java new file mode 100644 index 000000000..a182848eb --- /dev/null +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/encryption/SecurityManagerTest.java @@ -0,0 +1,54 @@ +package com.apptentive.android.sdk.encryption; + +import com.apptentive.android.sdk.InstrumentationTestCaseBase; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.*; + +public class SecurityManagerTest extends InstrumentationTestCaseBase { + private static final int TEST_DATA_SIZE = 8096; + + @Before + public void setup() { + SecurityManager.clear(getContext()); + SecurityManager.init(getContext()); + } + + @Test + public void testDataEncryptionDecryption() throws Exception { + EncryptionKey key = SecurityManager.getMasterKey(); + + // Set up the test data + byte[] testData = new byte[TEST_DATA_SIZE]; + new Random().nextBytes(testData); + + byte[] cipherText = Encryptor.encrypt(key, testData); + assertNotNull(cipherText); + + byte[] plainText = Encryptor.decrypt(key, cipherText); + assertNotNull(plainText); + + assertTrue(Arrays.equals(plainText, testData)); + } + + @Test + public void testStringEncryptionDecryption() throws Exception { + EncryptionKey key = SecurityManager.getMasterKey(); + + // Set up the test data + String testData = "Test data"; + + byte[] cipherText = Encryptor.encrypt(key, testData); + assertNotNull(cipherText); + + String plainText = Encryptor.decryptString(key, cipherText); + assertNotNull(plainText); + + assertEquals(plainText, testData); + } +} \ 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 index 39ae07744..4d1af0097 100644 --- a/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelperTest.java +++ b/apptentive/src/androidTest/java/com/apptentive/android/sdk/storage/ApptentiveDatabaseHelperTest.java @@ -12,6 +12,7 @@ import android.support.test.InstrumentationRegistry; import android.support.test.runner.AndroidJUnit4; +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.model.AppReleasePayload; import com.apptentive.android.sdk.model.DevicePayload; import com.apptentive.android.sdk.model.EventPayload; @@ -88,7 +89,7 @@ class ApptentiveDatabaseMockHelper extends ApptentiveDatabaseHelper { " ORDER BY " + PayloadEntry.COLUMN_PRIMARY_KEY; ApptentiveDatabaseMockHelper(Context context) { - super(context); + super(context, EncryptionKey.NULL); } List listPayloads(SQLiteDatabase db) throws JSONException { 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 82c71c1fa..f1343377a 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveInternal.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveInternal.java @@ -31,6 +31,7 @@ import com.apptentive.android.sdk.conversation.ConversationManager; import com.apptentive.android.sdk.conversation.ConversationProxy; import com.apptentive.android.sdk.debug.LogMonitor; +import com.apptentive.android.sdk.encryption.SecurityManager; import com.apptentive.android.sdk.lifecycle.ApptentiveActivityLifecycleCallbacks; import com.apptentive.android.sdk.model.Configuration; import com.apptentive.android.sdk.model.EventPayload; @@ -161,15 +162,17 @@ private ApptentiveInternal(Application application, String apptentiveKey, String this.apptentiveSignature = apptentiveSignature; this.serverUrl = serverUrl; + SecurityManager.init(application.getApplicationContext()); + 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)); + conversationManager = new ConversationManager(appContext, Util.getInternalDir(appContext, CONVERSATIONS_DIR, true), SecurityManager.getMasterKey()); appRelease = AppReleaseManager.generateCurrentAppRelease(application, this); - taskManager = new ApptentiveTaskManager(appContext, apptentiveHttpClient); + taskManager = new ApptentiveTaskManager(appContext, apptentiveHttpClient, SecurityManager.getMasterKey()); ApptentiveNotificationCenter.defaultCenter() .addObserver(NOTIFICATION_CONVERSATION_STATE_DID_CHANGE, this) 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 f6dabc71b..f160150ed 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/ApptentiveLogTag.java @@ -15,5 +15,6 @@ public enum ApptentiveLogTag { UTIL, TROUBLESHOOT, ADVERTISER_ID, - PARTNERS + PARTNERS, + SECURITY } 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 index f2c8507d6..38c7678b4 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveHttpClient.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/comm/ApptentiveHttpClient.java @@ -216,7 +216,7 @@ private HttpRequest createPayloadRequest(PayloadData payload) { request.setRequestProperty("Authorization", "Bearer " + authToken); } - if (payload.isEncrypted()) { + if (payload.isAuthenticated()) { request.setRequestProperty("APPTENTIVE-ENCRYPTED", Boolean.TRUE); } 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 index 5baa60c4b..550042a19 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/Conversation.java @@ -15,7 +15,6 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.comm.ApptentiveHttpClient; -import com.apptentive.android.sdk.debug.Assert; import com.apptentive.android.sdk.model.DevicePayload; import com.apptentive.android.sdk.model.EventPayload; import com.apptentive.android.sdk.model.Payload; @@ -34,6 +33,7 @@ import com.apptentive.android.sdk.storage.DeviceDataChangedListener; import com.apptentive.android.sdk.storage.DeviceManager; import com.apptentive.android.sdk.storage.EncryptedFileSerializer; +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.storage.EventData; import com.apptentive.android.sdk.storage.FileSerializer; import com.apptentive.android.sdk.storage.IntegrationConfig; @@ -58,8 +58,8 @@ import static com.apptentive.android.sdk.ApptentiveHelper.checkConversationQueue; import static com.apptentive.android.sdk.ApptentiveHelper.conversationDataQueue; import static com.apptentive.android.sdk.ApptentiveHelper.conversationQueue; +import static com.apptentive.android.sdk.ApptentiveLog.hideIfSanitized; import static com.apptentive.android.sdk.ApptentiveNotifications.*; -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.ApptentiveLogTag.*; @@ -75,20 +75,15 @@ public class Conversation implements DataChangedListener, Destroyable, DeviceDat private ConversationData conversationData; /** - * Encryption key for payloads. A hex encoded String. + * Encryption key for storing conversation data on disk. */ - private String encryptionKey; + private @NonNull EncryptionKey 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 */ @@ -128,20 +123,25 @@ protected void execute() { } }; - public Conversation(File conversationDataFile, File conversationMessagesFile) { + public Conversation(File conversationDataFile, File conversationMessagesFile, @NonNull EncryptionKey encryptionKey) { if (conversationDataFile == null) { throw new IllegalArgumentException("Data file is null"); } if (conversationMessagesFile == null) { throw new IllegalArgumentException("Messages file is null"); } + if (encryptionKey == null) { + throw new IllegalArgumentException("Data encryption key is null"); + } this.conversationDataFile = conversationDataFile; this.conversationMessagesFile = conversationMessagesFile; + this.encryptionKey = encryptionKey; conversationData = new ConversationData(); - FileMessageStore messageStore = new FileMessageStore(conversationMessagesFile); + FileMessageStore messageStore = new FileMessageStore(conversationMessagesFile, encryptionKey); + messageStore.migrateLegacyStorage(); 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 } @@ -163,6 +163,7 @@ public void addPayload(Payload payload) { payload.setConversationId(getConversationId()); payload.setToken(getConversationToken()); payload.setEncryptionKey(getEncryptionKey()); + payload.setAuthenticated(isAuthenticated()); // TODO: don't use singleton here ApptentiveInternal.getInstance().getApptentiveTaskManager().addPayload(payload); @@ -334,36 +335,47 @@ public void scheduleSaveConversationData() { */ private synchronized void saveConversationData() throws SerializerException { if (ApptentiveLog.canLog(ApptentiveLog.Level.VERBOSE)) { - ApptentiveLog.v(CONVERSATION, "Saving %sconversation data...", hasState(LOGGED_IN) ? "encrypted " : ""); + ApptentiveLog.v(CONVERSATION, "Saving conversation data..."); ApptentiveLog.v(CONVERSATION, "EventData: %s", getEventData().toString()); ApptentiveLog.v(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); - } - + FileSerializer serializer = new EncryptedFileSerializer(conversationDataFile, encryptionKey); serializer.serialize(conversationData); ApptentiveLog.v(CONVERSATION, "Conversation data saved (took %d ms)", System.currentTimeMillis() - start); } - synchronized void loadConversationData() throws SerializerException { + /** + * Attempts to migrate from the legacy clear text format. + * + * @return false if failed. + */ + boolean migrateConversationData() throws SerializerException { long start = System.currentTimeMillis(); - - FileSerializer serializer; - if (!StringUtils.isNullOrEmpty(encryptionKey)) { - serializer = new EncryptedFileSerializer(conversationDataFile, encryptionKey); - } else { - serializer = new FileSerializer(conversationDataFile); + File legacyConversationDataFile = Util.getUnencryptedFilename(conversationDataFile); + if (legacyConversationDataFile.exists()) { + try { + ApptentiveLog.d(CONVERSATION, "Migrating %sconversation data...", hasState(LOGGED_IN) ? "encrypted " : ""); + FileSerializer serializer = isAuthenticated() ? new EncryptedFileSerializer(legacyConversationDataFile, getEncryptionKey()) : + new FileSerializer(legacyConversationDataFile); + conversationData = (ConversationData) serializer.deserialize(); + ApptentiveLog.d(CONVERSATION, "Conversation data migrated (took %d ms)", System.currentTimeMillis() - start); + return true; + } finally { + boolean deleted = legacyConversationDataFile.delete(); + ApptentiveLog.d(CONVERSATION, "Legacy conversation file deleted: %b", deleted); + } } - ApptentiveLog.d(CONVERSATION, "Loading %sconversation data...", hasState(LOGGED_IN) ? "encrypted " : ""); + return false; + } + + void loadConversationData() throws SerializerException { + long start = System.currentTimeMillis(); + + FileSerializer serializer = new EncryptedFileSerializer(conversationDataFile, encryptionKey); + ApptentiveLog.d(CONVERSATION, "Loading conversation data..."); conversationData = (ConversationData) serializer.deserialize(); ApptentiveLog.d(CONVERSATION, "Conversation data loaded (took %d ms)", System.currentTimeMillis() - start); } @@ -493,6 +505,13 @@ public boolean hasState(ConversationState... states) { return false; } + /** + * Returns true if this conversation belongs to a logged-in user. + */ + public boolean isAuthenticated() { + return hasState(LOGGED_IN); + } + /** * Returns true if conversation is in "active" state (after receiving server response) */ @@ -670,12 +689,12 @@ public synchronized File getConversationMessagesFile() { return conversationMessagesFile; } - public String getEncryptionKey() { - return encryptionKey; + public void setEncryptionKey(@NonNull EncryptionKey encryptionKey) { + this.encryptionKey = encryptionKey; } - void setEncryptionKey(String encryptionKey) { - this.encryptionKey = encryptionKey; + public @NonNull EncryptionKey getEncryptionKey() { + return encryptionKey; } public String getUserId() { @@ -714,21 +733,31 @@ public void setPushIntegration(int pushProvider, String token) { * Checks the internal consistency of the conversation object (temporary solution) */ void checkInternalConsistency() throws IllegalStateException { + if (encryptionKey == null) { + throw new IllegalStateException("Missing encryption key"); + } + 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; + case LOGGED_OUT: + throw new IllegalStateException("Invalid conversation state: " + state); default: break; } } //endregion + + @Override + public String toString() { + return StringUtils.format("Conversation: localId=%s id=%s state=%s token=%s", + getLocalIdentifier(), + getConversationId(), + getState(), + hideIfSanitized(getConversationToken())); + } } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationLoadException.java b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationLoadException.java new file mode 100644 index 000000000..e30fb830c --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationLoadException.java @@ -0,0 +1,7 @@ +package com.apptentive.android.sdk.conversation; + +public class ConversationLoadException extends Exception { + public ConversationLoadException(String message) { + super(message); + } +} 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 index 93c84f29d..5fcda85f2 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationManager.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationManager.java @@ -8,6 +8,7 @@ import android.content.Context; import android.content.SharedPreferences; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.apptentive.android.sdk.Apptentive; @@ -30,6 +31,7 @@ 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.encryption.EncryptionKey; import com.apptentive.android.sdk.storage.Sdk; import com.apptentive.android.sdk.storage.SdkManager; import com.apptentive.android.sdk.storage.SerializerException; @@ -43,7 +45,6 @@ import org.json.JSONObject; import java.io.File; -import java.io.IOException; import java.lang.ref.WeakReference; import java.util.List; @@ -56,6 +57,7 @@ import static com.apptentive.android.sdk.conversation.ConversationState.*; import static com.apptentive.android.sdk.debug.Assert.*; import static com.apptentive.android.sdk.util.Constants.CONVERSATION_METADATA_FILE; +import static com.apptentive.android.sdk.util.Constants.CONVERSATION_METADATA_FILE_LEGACY_V1; import static com.apptentive.android.sdk.util.StringUtils.isNullOrEmpty; /** @@ -77,7 +79,12 @@ public class ConversationManager { /** * A basic directory for storing conversation-related data. */ - private final File apptentiveConversationsStorageDir; + private final File conversationsStorageDir; + + /** + * A private encryption key for securing SDK files. + */ + private final EncryptionKey encryptionKey; // FIXME: rename field /** * Current state of conversation metadata. @@ -87,13 +94,22 @@ public class ConversationManager { private Conversation activeConversation; private ConversationProxy activeConversationProxy; - public ConversationManager(Context context, File apptentiveConversationsStorageDir) { + public ConversationManager(@NonNull Context context, @NonNull File conversationsStorageDir, @NonNull EncryptionKey encryptionKey) { if (context == null) { throw new IllegalArgumentException("Context is null"); } + if (conversationsStorageDir == null) { + throw new IllegalArgumentException("Conversation storage dir is null"); + } + + if (encryptionKey == null) { + throw new IllegalArgumentException("Encryption key is null"); + } + this.contextRef = new WeakReference<>(context.getApplicationContext()); - this.apptentiveConversationsStorageDir = apptentiveConversationsStorageDir; + this.conversationsStorageDir = conversationsStorageDir; + this.encryptionKey = encryptionKey; ApptentiveNotificationCenter.defaultCenter() .addObserver(NOTIFICATION_APP_ENTERED_FOREGROUND, new ApptentiveNotificationObserver() { @@ -173,28 +189,27 @@ public boolean loadActiveConversation(Context context) { return false; } - private @Nullable Conversation loadActiveConversationGuarded() throws IOException { + private @Nullable Conversation loadActiveConversationGuarded() { // try to load an active conversation from metadata first try { if (conversationMetadata.hasItems()) { return loadConversationFromMetadata(conversationMetadata); } + + // try to load legacy conversation + Conversation legacyConversation = migrateLegacyConversation(getContext()); + if (legacyConversation != null) { + return legacyConversation; + } } catch (Exception e) { ApptentiveLog.e(e, "Exception while loading conversation"); } // no active conversations: create a new one ApptentiveLog.i(CONVERSATION, "Creating 'anonymous' conversation..."); - File dataFile = new File(apptentiveConversationsStorageDir, "conversation-" + Util.generateRandomFilename()); - File messagesFile = new File(apptentiveConversationsStorageDir, "messages-" + Util.generateRandomFilename()); - Conversation conversation = new Conversation(dataFile, messagesFile); - - // attempt to migrate a legacy conversation (if any) - if (migrateLegacyConversation(conversation)) { - return conversation; - } - - // if there is no Legacy Conversation, then just connect it to the server. + File dataFile = generateConversationDataFilename(); + File messagesFile = generateMessagesFilename(); + Conversation conversation = new Conversation(dataFile, messagesFile, encryptionKey); conversation.setState(ANONYMOUS_PENDING); fetchConversationToken(conversation); return conversation; @@ -204,7 +219,7 @@ public boolean loadActiveConversation(Context context) { * Attempts to load an existing conversation based on metadata file * @return null is only logged out conversations available */ - private @Nullable Conversation loadConversationFromMetadata(ConversationMetadata metadata) throws SerializerException { + private @Nullable Conversation loadConversationFromMetadata(ConversationMetadata metadata) throws SerializerException, ConversationLoadException { // we're going to scan metadata in attempt to find existing conversations ConversationMetadataItem item; @@ -245,59 +260,50 @@ public boolean loadActiveConversation(Context context) { return null; } - /** - * Attempts to migrate a legacy conversation - * @return true is succeed - */ - private boolean migrateLegacyConversation(Conversation conversation) { - try { - return migrateLegacyConversationGuarded(conversation); - } catch (Exception e) { - ApptentiveLog.e(e, "Exception while migrating legacy conversation"); - } - return false; - } - - private boolean migrateLegacyConversationGuarded(Conversation conversation) { + private @Nullable Conversation migrateLegacyConversation(Context context) { // 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)) { - ApptentiveLog.i(CONVERSATION, "Migrating an existing legacy conversation to the new format..."); - - 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) { - conversation.setState(LEGACY_PENDING); - conversation.setConversationToken(legacyConversationToken); - - Migrator migrator = new Migrator(getContext(), prefs, conversation); - migrator.migrate(); - - ApptentiveLog.v(CONVERSATION, "Fetching legacy conversation..."); - fetchLegacyConversation(conversation) - // 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 true; - } + if (isNullOrEmpty(legacyConversationToken)) { + return null; + } - ApptentiveLog.w(CONVERSATION, "Unable to migrate legacy conversation: data format is outdated!"); + ApptentiveLog.i(CONVERSATION, "Migrating an existing legacy conversation to the new format..."); + + // remove the legacy data to avoid further migration + prefs.edit() + .remove(Constants.PREF_KEY_CONVERSATION_TOKEN) + .remove(Constants.PREF_KEY_POLL_FOR_INTERACTIONS) + .apply(); + + 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) { + ApptentiveLog.i(CONVERSATION, "Creating 'legacy' conversation..."); + + File dataFile = generateConversationDataFilename(); + File messagesFile = generateMessagesFilename(); + Conversation conversation = new Conversation(dataFile, messagesFile, encryptionKey); + conversation.setState(LEGACY_PENDING); + conversation.setConversationToken(legacyConversationToken); + + // migrate conversation data (events, etc) + Migrator migrator = new Migrator(context, prefs, conversation); + migrator.migrate(); + + ApptentiveLog.v(CONVERSATION, "Fetching legacy conversation..."); + fetchLegacyConversation(conversation); + + return conversation; } - return false; + return null; } private HttpRequest fetchLegacyConversation(final Conversation conversation) { @@ -370,16 +376,33 @@ public void onFail(HttpJsonRequest request, String reason) { return request; } - private Conversation loadConversation(ConversationMetadataItem item) throws SerializerException { + private Conversation loadConversation(ConversationMetadataItem item) throws SerializerException, ConversationLoadException { checkConversationQueue(); + // logged-in conversations should use an encryption key which was received from the backend. + EncryptionKey conversationEncryptionKey = encryptionKey; + if (LOGGED_IN.equals(item.getConversationState())) { + conversationEncryptionKey = item.getConversationEncryptionKey(); + if (conversationEncryptionKey == null) { + throw new ConversationLoadException("Missing conversation encryption key"); + } + } + // 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 + final Conversation conversation = new Conversation(item.getDataFile(), item.getMessagesFile(), conversationEncryptionKey); + conversation.setState(item.getConversationState()); // set the state same as the item's state conversation.setUserId(item.getUserId()); conversation.setConversationToken(item.getConversationToken()); // TODO: this would be overwritten by the next call - conversation.loadConversationData(); + + // try to migrate legacy conversation first + boolean migrated = conversation.migrateConversationData(); + + // if failed - load from encrypted data + if (!migrated) { + conversation.loadConversationData(); + } + + // check inconsistency conversation.checkInternalConsistency(); return conversation; @@ -623,25 +646,21 @@ public void onFail(HttpJsonRequest request, String reason) { private void updateMetadataItems(Conversation conversation) { checkConversationQueue(); - ApptentiveLog.v(CONVERSATION, "Updating metadata: state=%s localId=%s conversationId=%s token=%s", - conversation.getState(), - conversation.getLocalIdentifier(), - conversation.getConversationId(), - hideIfSanitized(conversation.getConversationToken())); + ApptentiveLog.v(CONVERSATION, "Updating metadata: %s", conversation); // 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; + if (LOGGED_IN.equals(item.getConversationState())) { + item.setConversationState(LOGGED_OUT); } } } // delete sensitive information for (ConversationMetadataItem item : conversationMetadata) { - item.encryptionKey = null; - item.conversationToken = null; + item.setConversationEncryptionKey(null); + item.setConversationToken(null); } // update the state of the corresponding item @@ -651,18 +670,17 @@ private void updateMetadataItems(Conversation conversation) { conversationMetadata.addItem(item); } else { assertTrue(conversation.getConversationId() != null || conversation.hasState(ANONYMOUS_PENDING) || conversation.hasState(LEGACY_PENDING), "Missing conversation id for state: %s", conversation.getState()); - item.conversationId = conversation.getConversationId(); + item.setConversationId(conversation.getConversationId()); } - item.state = conversation.getState(); + item.setConversationState(conversation.getState()); if (conversation.hasActiveState()) { - item.conversationToken = notNull(conversation.getConversationToken()); + item.setConversationToken(notNull(conversation.getConversationToken())); } - // update encryption key (if necessary) if (conversation.hasState(LOGGED_IN)) { - item.encryptionKey = notNull(conversation.getEncryptionKey()); - item.userId = notNull(conversation.getUserId()); + item.setConversationEncryptionKey(notNull(conversation.getEncryptionKey())); + item.setUserId(notNull(conversation.getUserId())); } // apply changes @@ -677,13 +695,27 @@ private ConversationMetadata resolveMetadata() { checkConversationQueue(); try { - File metaFile = new File(apptentiveConversationsStorageDir, CONVERSATION_METADATA_FILE); + // attempt to load the encrypted metadata file + File metaFile = new File(conversationsStorageDir, CONVERSATION_METADATA_FILE); if (metaFile.exists()) { ApptentiveLog.v(CONVERSATION, "Loading metadata file: %s", metaFile); - return ObjectSerialization.deserialize(metaFile, ConversationMetadata.class); - } else { - ApptentiveLog.v(CONVERSATION, "Metadata file not found: %s", metaFile); + return ObjectSerialization.deserialize(metaFile, ConversationMetadata.class, encryptionKey); + } + + // attempt to load the legacy metadata file + metaFile = new File(conversationsStorageDir, CONVERSATION_METADATA_FILE_LEGACY_V1); + if (metaFile.exists()) { + ApptentiveLog.v(CONVERSATION, "Loading legacy v1 metadata file: %s", metaFile); + try { + return ObjectSerialization.deserialize(metaFile, ConversationMetadata.class); + } finally { + // we need to delete the legacy file to avoid the data being loaded next time + boolean fileDeleted = metaFile.delete(); + ApptentiveLog.v(CONVERSATION, "Legacy metadata file deleted: %b", fileDeleted); + } } + + ApptentiveLog.v(CONVERSATION, "No metadata files"); } catch (Exception e) { ApptentiveLog.e(CONVERSATION, e, "Exception while loading conversation metadata"); } @@ -699,11 +731,11 @@ private void saveMetadata() { ApptentiveLog.v(CONVERSATION, "Saving metadata: ", conversationMetadata.toString()); } long start = System.currentTimeMillis(); - File metaFile = new File(apptentiveConversationsStorageDir, CONVERSATION_METADATA_FILE); - ObjectSerialization.serialize(metaFile, conversationMetadata); + File metaFile = new File(conversationsStorageDir, CONVERSATION_METADATA_FILE); + ObjectSerialization.serialize(metaFile, conversationMetadata, encryptionKey); ApptentiveLog.v(CONVERSATION, "Saved metadata (took %d ms)", System.currentTimeMillis() - start); } catch (Exception e) { - ApptentiveLog.e(CONVERSATION, "Exception while saving metadata"); + ApptentiveLog.e(CONVERSATION, e, "Exception while saving metadata"); } } @@ -766,7 +798,7 @@ public boolean accept(ConversationMetadataItem item) { return; } - sendLoginRequest(conversationItem.conversationId, userId, token, callback); + sendLoginRequest(conversationItem.getConversationId(), userId, token, callback); return; } @@ -837,9 +869,10 @@ private void sendLoginRequest(String conversationId, final String userId, final public void onFinish(HttpJsonRequest request) { try { final JSONObject responseObject = request.getResponseObject(); - final String encryptionKey = responseObject.getString("encryption_key"); + final String conversationEncryptionKeyHex = responseObject.getString("encryption_key"); + final EncryptionKey conversationEncryptionKey = new EncryptionKey(conversationEncryptionKeyHex); final String incomingConversationId = responseObject.getString("id"); - handleLoginFinished(incomingConversationId, userId, token, encryptionKey); + handleLoginFinished(incomingConversationId, userId, token, conversationEncryptionKey); } catch (Exception e) { ApptentiveLog.e(CONVERSATION, e, "Exception while parsing login response"); handleLoginFailed("Internal error"); @@ -856,9 +889,9 @@ public void onFail(HttpJsonRequest request, String reason) { handleLoginFailed(reason); } - private void handleLoginFinished(final String conversationId, final String userId, final String token, final String encryptionKey) { + private void handleLoginFinished(final String conversationId, final String userId, final String token, final EncryptionKey conversationEncryptionKey) { checkConversationQueue(); - assertFalse(isNullOrEmpty(encryptionKey),"Login finished with missing encryption key."); + assertNotNull(conversationEncryptionKey,"Login finished with missing encryption key."); assertFalse(isNullOrEmpty(token), "Login finished with missing token."); try { @@ -873,14 +906,15 @@ public boolean accept(ConversationMetadataItem item) { }); if (conversationItem != null) { - conversationItem.conversationToken = token; - conversationItem.encryptionKey = encryptionKey; + conversationItem.setConversationState(LOGGED_IN); + conversationItem.setConversationToken(token); + conversationItem.setConversationEncryptionKey(conversationEncryptionKey); setActiveConversation(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()); - setActiveConversation(new Conversation(dataFile, messagesFile)); + File dataFile = generateConversationDataFilename(); + File messagesFile = generateMessagesFilename(); + setActiveConversation(new Conversation(dataFile, messagesFile, conversationEncryptionKey)); // TODO: if we don't set these here - device payload would return 4xx error code activeConversation.setDevice(DeviceManager.generateNewDevice(getContext())); @@ -889,7 +923,7 @@ public boolean accept(ConversationMetadataItem item) { } } - activeConversation.setEncryptionKey(encryptionKey); + activeConversation.setEncryptionKey(conversationEncryptionKey); activeConversation.setConversationToken(token); activeConversation.setConversationId(conversationId); activeConversation.setUserId(userId); @@ -928,9 +962,9 @@ private void sendFirstLoginRequest(final String userId, final String token, fina public void onFinish(HttpJsonRequest request) { try { final JSONObject responseObject = request.getResponseObject(); - final String encryptionKey = responseObject.getString("encryption_key"); + final EncryptionKey payloadEncryptionKey = new EncryptionKey(responseObject.getString("encryption_key")); final String incomingConversationId = responseObject.getString("id"); - handleLoginFinished(incomingConversationId, userId, token, encryptionKey); + handleLoginFinished(incomingConversationId, userId, token, payloadEncryptionKey); } catch (Exception e) { ApptentiveLog.e(CONVERSATION, e, "Exception while parsing login response"); handleLoginFailed("Internal error"); @@ -947,10 +981,10 @@ public void onFail(HttpJsonRequest request, String reason) { handleLoginFailed(reason); } - private void handleLoginFinished(final String conversationId, final String userId, final String token, final String encryptionKey) { + private void handleLoginFinished(final String conversationId, final String userId, final String token, final EncryptionKey conversationEncryptionKey) { checkConversationQueue(); assertNull(activeConversation, "Finished logging into new conversation, but one was already active."); - assertFalse(isNullOrEmpty(encryptionKey),"Login finished with missing encryption key."); + assertNotNull(conversationEncryptionKey,"Login finished with missing encryption key."); assertFalse(isNullOrEmpty(token), "Login finished with missing token."); try { @@ -963,21 +997,23 @@ public boolean accept(ConversationMetadataItem item) { }); if (conversationItem != null) { - conversationItem.conversationToken = token; - conversationItem.encryptionKey = encryptionKey; + conversationItem.setConversationState(LOGGED_IN); + conversationItem.setConversationToken(token); + conversationItem.setConversationEncryptionKey(conversationEncryptionKey); setActiveConversation(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()); - setActiveConversation(new Conversation(dataFile, messagesFile)); + File dataFile = generateConversationDataFilename(); + File messagesFile = generateMessagesFilename(); + Conversation conversation = new Conversation(dataFile, messagesFile, conversationEncryptionKey); + setActiveConversation(conversation); activeConversation.setAppRelease(appRelease); activeConversation.setSdk(sdk); activeConversation.setDevice(device); } - activeConversation.setEncryptionKey(encryptionKey); + activeConversation.setEncryptionKey(conversationEncryptionKey); activeConversation.setConversationToken(token); activeConversation.setConversationId(conversationId); activeConversation.setUserId(userId); @@ -1049,19 +1085,19 @@ private void printMetadata(ConversationMetadata metadata, String title) { "dataFile", "messagesFile", "conversationToken", - "encryptionKey" + "payloadEncryptionKey" }; int index = 1; for (ConversationMetadataItem item : items) { rows[index++] = new Object[] { - item.state, - item.localConversationId, - item.conversationId, - item.userId, - hideIfSanitized(item.dataFile), - hideIfSanitized(item.messagesFile), - hideIfSanitized(item.conversationToken), - hideIfSanitized(item.encryptionKey) + item.getConversationState(), + item.getLocalConversationId(), + item.getConversationId(), + item.getUserId(), + hideIfSanitized(item.getDataFile()), + hideIfSanitized(item.getMessagesFile()), + hideIfSanitized(item.getConversationToken()), + hideIfSanitized(item.getConversationEncryptionKey()) }; } @@ -1070,6 +1106,24 @@ private void printMetadata(ConversationMetadata metadata, String title) { //endregion + //region Helpers + + /** + * Generates a random name for a conversation data file + */ + private @NonNull File generateConversationDataFilename() { + return Util.getEncryptedFilename(new File(conversationsStorageDir, "conversation-" + Util.generateRandomFilename())); + } + + /** + * Generates a random name for a conversation messages file + */ + private @NonNull File generateMessagesFilename() { + return Util.getEncryptedFilename(new File(conversationsStorageDir, "messages-" + Util.generateRandomFilename())); + } + + //endregion + //region Getters/Setters public @Nullable Conversation getActiveConversation() { 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 index cd87f6ee1..d2d541467 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadata.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadata.java @@ -58,7 +58,7 @@ ConversationMetadataItem findItem(final ConversationState state) { return findItem(new Filter() { @Override public boolean accept(ConversationMetadataItem item) { - return state.equals(item.state); + return state.equals(item.getConversationState()); } }); } @@ -67,7 +67,7 @@ ConversationMetadataItem findItem(final Conversation conversation) { return findItem(new Filter() { @Override public boolean accept(ConversationMetadataItem item) { - return StringUtils.equal(item.localConversationId, conversation.getLocalIdentifier()); + return StringUtils.equal(item.getLocalConversationId(), conversation.getLocalIdentifier()); } }); } 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 index d15888a8b..a9dca49c2 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadataItem.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/ConversationMetadataItem.java @@ -1,13 +1,18 @@ package com.apptentive.android.sdk.conversation; +import android.support.annotation.Nullable; + +import com.apptentive.android.sdk.encryption.SecurityManager; import com.apptentive.android.sdk.serialization.SerializableObject; -import com.apptentive.android.sdk.util.StringUtils; +import com.apptentive.android.sdk.encryption.EncryptionKey; import java.io.DataInput; import java.io.DataOutput; import java.io.File; import java.io.IOException; +import static com.apptentive.android.sdk.ApptentiveLog.hideIfSanitized; +import static com.apptentive.android.sdk.util.Util.getEncryptedFilename; import static com.apptentive.android.sdk.util.Util.readNullableUTF; import static com.apptentive.android.sdk.util.Util.writeNullableUTF; @@ -19,42 +24,43 @@ public class ConversationMetadataItem implements SerializableObject { /** * The state of the target conversation */ - ConversationState state = ConversationState.UNDEFINED; + private ConversationState conversationState = ConversationState.UNDEFINED; /** * Local conversation ID */ - final String localConversationId; + private final String localConversationId; /** - * Conversation ID which was received from the backend + * Storage filename for conversation serialized data */ - String conversationId; + private final File dataFile; /** - * The token for active conversations + * Storage filename for conversation serialized messages */ - String conversationToken; + private final File messagesFile; /** - * Storage filename for conversation serialized data + * Conversation ID which was received from the backend */ - final File dataFile; + private @Nullable String conversationId; /** - * Storage filename for conversation serialized messages + * The token for active conversations */ - final File messagesFile; + private @Nullable String conversationToken; /** - * Key for encrypting payloads + * Key for encrypting logged-in conversations. We receive it from the server. Anonymous conversations + * would not have this key. */ - String encryptionKey; + private @Nullable EncryptionKey conversationEncryptionKey; /** * An optional user ID for logged in conversations */ - String userId; + private @Nullable String userId; public ConversationMetadataItem(String localConversationId, String conversationId, File dataFile, File messagesFile) { if (localConversationId == null) { @@ -79,10 +85,11 @@ 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); + dataFile = getEncryptedFilename(new File(in.readUTF())); + messagesFile = getEncryptedFilename(new File(in.readUTF())); + conversationState = ConversationState.valueOf(in.readByte()); + String conversationEncryptionKeyHex = readNullableUTF(in); + conversationEncryptionKey = conversationEncryptionKeyHex != null ? new EncryptionKey(conversationEncryptionKeyHex) : null; userId = readNullableUTF(in); } @@ -93,45 +100,73 @@ public void writeExternal(DataOutput out) throws IOException { writeNullableUTF(out, conversationToken); out.writeUTF(dataFile.getAbsolutePath()); out.writeUTF(messagesFile.getAbsolutePath()); - out.writeByte(state.ordinal()); - writeNullableUTF(out, encryptionKey); + out.writeByte(conversationState.ordinal()); + writeNullableUTF(out, conversationEncryptionKey != null ? conversationEncryptionKey.getHexKey() : null); writeNullableUTF(out, userId); } + public @Nullable String getConversationId() { + return conversationId; + } + public String getLocalConversationId() { return localConversationId; } - public String getConversationId() { - return conversationId; + public void setConversationId(String conversationId) { + this.conversationId = conversationId; + } + + public ConversationState getConversationState() { + return conversationState; } - public ConversationState getState() { - return state; + public void setConversationState(ConversationState conversationState) { + this.conversationState = conversationState; } - public String getEncryptionKey() { - return encryptionKey; + public @Nullable EncryptionKey getConversationEncryptionKey() { + return conversationEncryptionKey; } - public String getUserId() { + public void setConversationEncryptionKey(EncryptionKey conversationEncryptionKey) { + this.conversationEncryptionKey = conversationEncryptionKey; + } + + public @Nullable String getUserId() { return userId; } - public String getConversationToken() { + public void setUserId(String userId) { + this.userId = userId; + } + + public @Nullable String getConversationToken() { return conversationToken; } + public void setConversationToken(String conversationToken) { + this.conversationToken = conversationToken; + } + + public File getDataFile() { + return dataFile; + } + + public File getMessagesFile() { + return messagesFile; + } + @Override public String toString() { return "ConversationMetadataItem{" + - "state=" + state + + "conversationState=" + conversationState + ", localConversationId='" + localConversationId + '\'' + ", conversationId='" + conversationId + '\'' + - ", conversationToken='" + conversationToken + '\'' + + ", conversationToken='" + hideIfSanitized(conversationToken) + '\'' + ", dataFile=" + dataFile + ", messagesFile=" + messagesFile + - ", encryptionKey='" + encryptionKey + '\'' + + ", conversationEncryptionKey='" + hideIfSanitized(conversationEncryptionKey) + '\'' + ", userId='" + userId + '\'' + '}'; } 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 index d112e74ce..feeffd49c 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/conversation/FileMessageStore.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/conversation/FileMessageStore.java @@ -6,10 +6,10 @@ package com.apptentive.android.sdk.conversation; -import android.support.v4.util.AtomicFile; - import com.apptentive.android.sdk.ApptentiveLog; import com.apptentive.android.sdk.debug.Assert; +import com.apptentive.android.sdk.encryption.EncryptionKey; +import com.apptentive.android.sdk.encryption.Encryptor; import com.apptentive.android.sdk.model.ApptentiveMessage; import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; import com.apptentive.android.sdk.serialization.SerializableObject; @@ -17,17 +17,26 @@ import com.apptentive.android.sdk.util.StringUtils; import com.apptentive.android.sdk.util.Util; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; 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.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import static com.apptentive.android.sdk.ApptentiveLogTag.CONVERSATION; import static com.apptentive.android.sdk.ApptentiveLogTag.MESSAGES; import static com.apptentive.android.sdk.util.Util.readNullableBoolean; import static com.apptentive.android.sdk.util.Util.readNullableDouble; @@ -44,10 +53,20 @@ class FileMessageStore implements MessageStore { private final File file; private final List messageEntries; + private final EncryptionKey encryptionKey; private boolean shouldFetchFromFile; - FileMessageStore(File file) { + FileMessageStore(File file, EncryptionKey encryptionKey) { + if (file == null) { + throw new IllegalArgumentException("File is null"); + } + + if (encryptionKey == null) { + throw new IllegalArgumentException("Encryption key is null"); + } + this.file = file; + this.encryptionKey = encryptionKey; this.messageEntries = new ArrayList<>(); // we need a random access this.shouldFetchFromFile = true; // we would lazily read it from a file later } @@ -64,7 +83,7 @@ public synchronized void addOrUpdateMessages(ApptentiveMessage... apptentiveMess // Update existing.id = apptentiveMessage.getId(); existing.state = apptentiveMessage.getState().name(); - if (apptentiveMessage.isRead()) { // A apptentiveMessage can't be unread after being read. + if (apptentiveMessage.isRead()) { // A message can't be unread after being read. existing.isRead = true; } existing.json = apptentiveMessage.getJsonObject().toString(); @@ -135,7 +154,7 @@ public synchronized String getLastReceivedMessageId() throws Exception { } @Override - public synchronized int getUnreadMessageCount() throws Exception { + public synchronized int getUnreadMessageCount() { fetchEntries(); int count = 0; @@ -203,23 +222,21 @@ private synchronized void readFromFile() { } } - 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 List readFromFileGuarded() throws IOException, NoSuchPaddingException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException { + byte[] bytes = Encryptor.readFromEncryptedFile(encryptionKey, file); + ByteArrayInputStream bis = new ByteArrayInputStream(bytes); + + DataInputStream dis = new DataInputStream(bis); + 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; } private synchronized void writeToFile() { @@ -231,22 +248,15 @@ private synchronized void writeToFile() { shouldFetchFromFile = false; // mark it as not shouldFetchFromFile to keep a memory version } - private void writeToFileGuarded() throws IOException { - AtomicFile atomicFile = new AtomicFile(file); - FileOutputStream stream = null; - try { - stream = atomicFile.startWrite(); - DataOutputStream dos = new DataOutputStream(stream); - dos.writeByte(VERSION); - dos.writeInt(messageEntries.size()); - for (MessageEntry entry : messageEntries) { - entry.writeExternal(dos); - } - atomicFile.finishWrite(stream); - } catch (Exception e) { - atomicFile.failWrite(stream); - throw new IOException(e); + private void writeToFileGuarded() throws IOException, NoSuchPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(bos); + dos.writeByte(VERSION); + dos.writeInt(messageEntries.size()); + for (MessageEntry entry : messageEntries) { + entry.writeExternal(dos); } + Encryptor.writeToEncryptedFile(encryptionKey, file, bos.toByteArray()); } //endregion @@ -316,6 +326,47 @@ public String toString() { //endregion + //region Migration + + public void migrateLegacyStorage() { + try { + File unencryptedFile = Util.getUnencryptedFilename(file); + if (unencryptedFile.exists()) { + try { + List entries = readFromLegacyFile(unencryptedFile); + messageEntries.addAll(entries); + writeToFile(); + } finally { + boolean deleted = unencryptedFile.delete(); + ApptentiveLog.d(CONVERSATION, "Deleted legacy message storage: %b", deleted); + } + } + } catch (Exception e) { + ApptentiveLog.e(CONVERSATION, e, "Exception while migrating messages"); + } + } + + private static List readFromLegacyFile(File file) 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); + } + } + + //endregion + @Override public String toString() { return "FileMessageStore{" + diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/EncryptionKey.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/EncryptionKey.java new file mode 100644 index 000000000..6232047d1 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/EncryptionKey.java @@ -0,0 +1,65 @@ +package com.apptentive.android.sdk.encryption; + +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.apptentive.android.sdk.util.StringUtils; + +import java.security.Key; + +import javax.crypto.spec.SecretKeySpec; + +public class EncryptionKey { + public static final EncryptionKey NULL = new EncryptionKey(); + + static final String DEFAULT_TRANSFORMATION = "AES/CBC/PKCS5Padding"; + private static final String ALGORITHM = "AES"; + + private final Key key; + private final String hexKey; + private final String transformation; + + public EncryptionKey(@NonNull Key key, @NonNull String transformation) { + if (key == null) { + throw new IllegalArgumentException("Key is null"); + } + if (StringUtils.isNullOrEmpty(transformation)) { + throw new IllegalArgumentException("Cipher transformation is null or empty"); + } + + this.key = key; + this.transformation = transformation; + this.hexKey = null; + } + + public EncryptionKey(@NonNull String hexKey) { + if (StringUtils.isNullOrEmpty(hexKey)) { + throw new IllegalArgumentException("Hex key is null or empty"); + } + this.key = new SecretKeySpec(StringUtils.hexToBytes(hexKey), ALGORITHM); + this.transformation = DEFAULT_TRANSFORMATION; + this.hexKey = hexKey; + } + + private EncryptionKey() { + this.key = null; + this.hexKey = null; + this.transformation = ""; + } + + public boolean isNull() { + return key == null; + } + + @Nullable Key getSecretKey() { + return key; + } + + public @Nullable String getHexKey() { + return hexKey; + } + + public @NonNull String getTransformation() { + return transformation; + } +} 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 index 44a556b78..57f830e50 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/Encryptor.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/Encryptor.java @@ -6,9 +6,14 @@ package com.apptentive.android.sdk.encryption; -import com.apptentive.android.sdk.util.StringUtils; +import android.support.annotation.Nullable; +import android.support.v4.util.AtomicFile; -import java.io.UnsupportedEncodingException; +import com.apptentive.android.sdk.util.Util; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; import java.security.InvalidAlgorithmParameterException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; @@ -20,77 +25,127 @@ import javax.crypto.IllegalBlockSizeException; import javax.crypto.NoSuchPaddingException; import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; public class Encryptor { - + /** + * Initialization vector size + */ private static final int IV_SIZE = 16; - private SecretKeySpec key; + //region Encryption - /** - * 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"); + public static @Nullable byte[] encrypt(EncryptionKey encryptionKey, @Nullable String value) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + return value != null ? encrypt(encryptionKey, value.getBytes()) : null; } - Encryptor(byte[] keyBytes) { - this.key = new SecretKeySpec(keyBytes, "AES"); - } + public static @Nullable byte[] encrypt(EncryptionKey key, @Nullable byte[] plainText) throws NoSuchPaddingException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException, + InvalidKeyException { + if (key == null) { + throw new IllegalArgumentException("Encryption key is null"); + } + + if (plainText == null || key.isNull()) { + return plainText; + } - 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[] cipherText = encrypt(key, plainText, iv); 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 { + private static byte[] encrypt(EncryptionKey key, byte[] plainText, byte[] iv) 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); + Cipher cipher = Cipher.getInstance(key.getTransformation()); + cipher.init(Cipher.ENCRYPT_MODE, key.getSecretKey(), 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); + //endregion + + //region Decrypt + + public static @Nullable String decryptString(EncryptionKey encryptionKey, @Nullable byte[] encryptedBytes) throws NoSuchPaddingException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException { + byte[] decrypted = decrypt(encryptionKey, encryptedBytes); + return decrypted != null ? new String(decrypted) : null; } - public byte[] decrypt(byte[] ivAndCipherText) throws NoSuchPaddingException, - InvalidKeyException, - NoSuchAlgorithmException, - IllegalBlockSizeException, - BadPaddingException, - InvalidAlgorithmParameterException { + public static @Nullable byte[] decrypt(EncryptionKey key, @Nullable byte[] ivAndCipherText) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + if (key == null) { + throw new IllegalArgumentException("Encryption key is null"); + } + + if (ivAndCipherText == null || key.isNull()) { + return ivAndCipherText; + } + + 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); + return decrypt(key, cipherText, iv); } + + private static byte[] decrypt(EncryptionKey key, byte[] cipherText, byte[] iv) throws NoSuchPaddingException, + NoSuchAlgorithmException, + BadPaddingException, + IllegalBlockSizeException, + InvalidAlgorithmParameterException, + InvalidKeyException { + AlgorithmParameterSpec ivParameterSpec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance(key.getTransformation()); + cipher.init(Cipher.DECRYPT_MODE, key.getSecretKey(), ivParameterSpec); + return cipher.doFinal(cipherText); + } + + //endregion + + //region File IO + + public static void writeToEncryptedFile(EncryptionKey encryptionKey, File file, byte[] data) throws IOException, NoSuchPaddingException, InvalidAlgorithmParameterException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidKeyException { + AtomicFile atomicFile = new AtomicFile(file); + FileOutputStream stream = null; + boolean successful = false; + try { + stream = atomicFile.startWrite(); + stream.write(encrypt(encryptionKey, data)); + atomicFile.finishWrite(stream); + successful = true; + } finally { + if (!successful) { + atomicFile.failWrite(stream); + } + } + } + + public static byte[] readFromEncryptedFile(EncryptionKey encryptionKey, File file) throws IOException, NoSuchPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException { + final byte[] bytes = Util.readBytes(file); + return decrypt(encryptionKey, bytes); + } + + //endregion } diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/SecurityManager.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/SecurityManager.java new file mode 100644 index 000000000..94ead3b4b --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/SecurityManager.java @@ -0,0 +1,130 @@ +package com.apptentive.android.sdk.encryption; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.support.annotation.NonNull; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.resolvers.KeyResolver; +import com.apptentive.android.sdk.encryption.resolvers.KeyResolverFactory; +import com.apptentive.android.sdk.util.StringUtils; + +import java.util.UUID; + +import static com.apptentive.android.sdk.ApptentiveLog.hideIfSanitized; +import static com.apptentive.android.sdk.ApptentiveLogTag.SECURITY; + +/** + * Class responsible for managing the master encryption key (generation, storage and retrieval). + */ +public final class SecurityManager { + private static final String PREFS_KEY_ALIAS = "alias"; + private static final String PREFS_SDK_VERSION_CODE = "version_code"; + + private static EncryptionKey masterKey; + + //region Initialization + + public static void init(Context context) { + if (context == null) { + throw new IllegalArgumentException("Context is null"); + } + + // get the name of the alias + KeyInfo keyInfo = resolveKeyInfo(context); + ApptentiveLog.v(SECURITY, "Secret key info: %s", keyInfo); + + // load or generate the key + masterKey = resolveMasterKey(context, keyInfo); + } + + public static void clear(Context context) { + SharedPreferences prefs = getPrefs(context); + prefs.edit().clear().apply(); + } + + private static KeyInfo resolveKeyInfo(Context context) { + // in order to avoid potential naming collisions we would generate a unique name for the alias and + // store it in the SharedPreferences + SharedPreferences prefs = getPrefs(context); + String keyAlias = prefs.getString(PREFS_KEY_ALIAS, null); + int versionCode = prefs.getInt(PREFS_SDK_VERSION_CODE, 0); + if (StringUtils.isNullOrEmpty(keyAlias) || versionCode == 0) { + keyAlias = generateUniqueKeyAlias(); + versionCode = Build.VERSION.SDK_INT; + prefs.edit() + .putString(PREFS_KEY_ALIAS, keyAlias) + .putInt(PREFS_SDK_VERSION_CODE, versionCode) + .apply(); + ApptentiveLog.v(SECURITY, "Generated new key info"); + } + + return new KeyInfo(keyAlias, versionCode); + } + + private static @NonNull EncryptionKey resolveMasterKey(Context context, KeyInfo keyInfo) { + try { + KeyResolver keyResolver = KeyResolverFactory.createKeyResolver(keyInfo.versionCode); + return keyResolver.resolveKey(context, keyInfo.alias); + } catch (Exception e) { + ApptentiveLog.e(SECURITY, e, "Exception while resolving secret key for alias '%s'. Encryption might not work correctly!", hideIfSanitized(keyInfo.alias)); + } + + return EncryptionKey.NULL; + } + + //endregion + + //region Getters/Setters + + public static @NonNull EncryptionKey getMasterKey() { + return masterKey; + } + //endregion + + //region Helpers + + private static String generateUniqueKeyAlias() { + return "apptentive-key-" + UUID.randomUUID().toString(); + } + + private static SharedPreferences getPrefs(Context context) { + return context.getSharedPreferences("com.apptentive.sdk.security", Context.MODE_PRIVATE); + } + + //endregion + + //region Helper classes + + static class KeyInfo { + /** + * Alias name for KeyStore. + */ + final String alias; + + /** + * Android SDK version code at the time the target key was generated. + */ + final int versionCode; + + KeyInfo(String alias, int versionCode) { + if (StringUtils.isNullOrEmpty(alias)) { + throw new IllegalArgumentException("Key alias name is null or empty"); + } + if (versionCode < 1) { + throw new IllegalArgumentException("Invalid SDK version code"); + } + + this.alias = alias; + this.versionCode = versionCode; + } + + @Override + public String toString() { + return StringUtils.format("KeyInfo: alias=%s versionCode=%d", hideIfSanitized(alias), versionCode); + } + } + + //endregion +} \ No newline at end of file diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver.java new file mode 100644 index 000000000..28cd1c66e --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver.java @@ -0,0 +1,11 @@ +package com.apptentive.android.sdk.encryption.resolvers; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.apptentive.android.sdk.encryption.EncryptionKey; + +public interface KeyResolver { + @NonNull EncryptionKey resolveKey(Context context, String keyAlias) throws Exception; +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver18.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver18.java new file mode 100644 index 000000000..9601ee25f --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver18.java @@ -0,0 +1,234 @@ +package com.apptentive.android.sdk.encryption.resolvers; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.security.KeyPairGeneratorSpec; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; +import android.util.Base64; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.EncryptionKey; +import com.apptentive.android.sdk.util.ObjectUtils; +import com.apptentive.android.sdk.util.StringUtils; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.util.Calendar; + +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.security.auth.x500.X500Principal; + +import static com.apptentive.android.sdk.ApptentiveLogTag.SECURITY; + +@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2) class KeyResolver18 implements KeyResolver { + private static final String DEFAULT_KEY_ALGORITHM = "AES"; + private static final String WRAPPER_KEY_ALGORITHM = "RSA"; + + private static final String DEFAULT_TRANSFORMATION = "AES/CBC/PKCS7Padding"; + private static final String WRAPPER_TRANSFORMATION = "RSA/ECB/PKCS1Padding"; + + private static final String DEFAULT_PROVIDER = "BC"; + private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; + + private static final String PREFS_NAME_SYMMETRIC_KEY = "com.apptentive.sdk.security.keys"; + private static final String PREFS_KEY_SYMMETRIC_KEY = "key"; + + @Override + public @NonNull EncryptionKey resolveKey(Context context, String keyAlias) throws UnrecoverableKeyException, + CertificateException, + NoSuchAlgorithmException, + KeyStoreException, + IOException, + NoSuchProviderException, + InvalidAlgorithmParameterException, + NoSuchPaddingException, + InvalidKeyException, + IllegalBlockSizeException { + SecretKey secretKey = resolveSymmetricKey(context, keyAlias); + return new EncryptionKey(secretKey, DEFAULT_TRANSFORMATION); + } + + //region Keys + + private SecretKey resolveSymmetricKey(Context context, String keyAlias) throws UnrecoverableKeyException, + CertificateException, + NoSuchAlgorithmException, + KeyStoreException, + IOException, + NoSuchProviderException, + InvalidAlgorithmParameterException, + NoSuchPaddingException, + InvalidKeyException, + IllegalBlockSizeException { + // 1. try to resolve the wrapper key (load an existing one from the key store or generate a new one) + KeyPair wrapperKey = resolveWrapperKey(context, keyAlias); + + // 2. try to load and existing symmetric key from un-secure device storage + SecretKey secretKey = loadSymmetricKey(context, wrapperKey); + if (secretKey != null) { + return secretKey; + } + + // 3. generate and store a new symmetric key in the un-secure device storage. + return generateSymmetricKey(context, wrapperKey); + } + + private SecretKey generateSymmetricKey(Context context, KeyPair wrapperKey) throws NoSuchProviderException, + NoSuchAlgorithmException, + NoSuchPaddingException, + InvalidKeyException, + IllegalBlockSizeException { + SecretKey secretKey = generateSymmetricKey(); + storeSymmetricKey(context, secretKey, wrapperKey); + return secretKey; + } + + private static SecretKey generateSymmetricKey() throws NoSuchProviderException, + NoSuchAlgorithmException { + KeyGenerator keyGenerator = KeyGenerator.getInstance(DEFAULT_KEY_ALGORITHM, DEFAULT_PROVIDER); + return keyGenerator.generateKey(); + } + + private static void storeSymmetricKey(Context context, SecretKey symmetricKey, KeyPair wrapperKey) throws IllegalBlockSizeException, + InvalidKeyException, + NoSuchAlgorithmException, + NoSuchPaddingException { + String encryptedSymmetricKey = wrapSymmetricKey(wrapperKey, symmetricKey); + getKeyPrefs(context).edit() + .putString(PREFS_KEY_SYMMETRIC_KEY, encryptedSymmetricKey) + .apply(); + } + + /** + * Attempts to load an existing symmetric key or return null if failed. + * Multistep process: + * 1. Load and encrypted symmetric key data from the shared preferences. + * 2. Unwraps the key using wrapperKey + */ + private static @Nullable SecretKey loadSymmetricKey(Context context, KeyPair wrapperKey) throws NoSuchPaddingException, + NoSuchAlgorithmException, + InvalidKeyException { + String encryptedSymmetricKey = getKeyPrefs(context).getString(PREFS_KEY_SYMMETRIC_KEY, null); + if (StringUtils.isNullOrEmpty(encryptedSymmetricKey)) { + return null; + } + + return unwrapSymmetricKey(wrapperKey, encryptedSymmetricKey); + } + + private static KeyPair resolveWrapperKey(Context context, String keyAlias) throws UnrecoverableKeyException, + CertificateException, + NoSuchAlgorithmException, + KeyStoreException, + IOException, + NoSuchProviderException, + InvalidAlgorithmParameterException { + KeyPair existingWrapperKey = loadExistingWrapperKey(keyAlias); + if (existingWrapperKey != null) { + ApptentiveLog.v(SECURITY, "Loaded existing asymmetric wrapper key (alias: %s)", keyAlias); + return existingWrapperKey; + } + + KeyPair wrapperKey = generateWrapperKey(context, keyAlias); + ApptentiveLog.v(SECURITY, "Generated new asymmetric wrapper key (alias: %s)", keyAlias); + return wrapperKey; + } + + + private static @Nullable KeyPair loadExistingWrapperKey(String keyAlias) throws KeyStoreException, + CertificateException, + NoSuchAlgorithmException, + IOException, + UnrecoverableKeyException { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); + keyStore.load(null); + PrivateKey privateKey = ObjectUtils.as(keyStore.getKey(keyAlias, null), PrivateKey.class); + if (privateKey == null) { + return null; + } + + Certificate certificate = keyStore.getCertificate(keyAlias); + if (certificate == null) { + return null; + } + + PublicKey publicKey = certificate.getPublicKey(); + if (publicKey == null) { + return null; + } + + return new KeyPair(publicKey, privateKey); + } + + private static KeyPair generateWrapperKey(Context context, String alias) throws NoSuchProviderException, + NoSuchAlgorithmException, + InvalidAlgorithmParameterException { + KeyPairGenerator generator = KeyPairGenerator.getInstance(WRAPPER_KEY_ALGORITHM, KEYSTORE_PROVIDER); + Calendar startDate = Calendar.getInstance(); + Calendar endDate = Calendar.getInstance(); + endDate.add(Calendar.YEAR, 25); + + KeyPairGeneratorSpec.Builder builder = new KeyPairGeneratorSpec.Builder(context) + .setAlias(alias) + .setSerialNumber(BigInteger.ONE) + .setSubject(new X500Principal("CN=${alias} CA Certificate")) + .setStartDate(startDate.getTime()) + .setEndDate(endDate.getTime()); + + generator.initialize(builder.build()); + return generator.generateKeyPair(); + } + + //endregion + + //region Key Wrapping + + private static String wrapSymmetricKey(KeyPair wrapperKey, SecretKey symmetricKey) throws NoSuchPaddingException, + NoSuchAlgorithmException, + InvalidKeyException, + IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(WRAPPER_TRANSFORMATION); + cipher.init(Cipher.WRAP_MODE, wrapperKey.getPublic()); + byte[] decodedData = cipher.wrap(symmetricKey); + return Base64.encodeToString(decodedData, Base64.DEFAULT); + } + + private static SecretKey unwrapSymmetricKey(KeyPair wrapperKey, String wrappedKeyData) throws NoSuchPaddingException, + NoSuchAlgorithmException, + InvalidKeyException { + byte[] encryptedKeyData = Base64.decode(wrappedKeyData, Base64.DEFAULT); + Cipher cipher = Cipher.getInstance(WRAPPER_TRANSFORMATION); + cipher.init(Cipher.UNWRAP_MODE, wrapperKey.getPrivate()); + return (SecretKey) cipher.unwrap(encryptedKeyData, DEFAULT_KEY_ALGORITHM, Cipher.SECRET_KEY); + } + + //endregion + + //region Helpers + + private static SharedPreferences getKeyPrefs(Context context) { + return context.getSharedPreferences(PREFS_NAME_SYMMETRIC_KEY, Context.MODE_PRIVATE); + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver23.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver23.java new file mode 100644 index 000000000..8914992b8 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver23.java @@ -0,0 +1,75 @@ +package com.apptentive.android.sdk.encryption.resolvers; + +import android.content.Context; +import android.os.Build; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.RequiresApi; + +import com.apptentive.android.sdk.encryption.EncryptionKey; +import com.apptentive.android.sdk.util.ObjectUtils; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableEntryException; +import java.security.cert.CertificateException; + +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; + +@RequiresApi(api = Build.VERSION_CODES.M) +class KeyResolver23 implements KeyResolver { + private static final String CIPHER_TRANSFORMATION = "AES/CBC/PKCS7Padding"; + private static final String KEYSTORE_PROVIDER = "AndroidKeyStore"; + + @Override + public @NonNull EncryptionKey resolveKey(Context context, String keyAlias) throws KeyStoreException, + CertificateException, + NoSuchAlgorithmException, + UnrecoverableEntryException, + IOException, + NoSuchProviderException, + InvalidAlgorithmParameterException { + SecretKey secretKey = resolveSecretKey(keyAlias); + return new EncryptionKey(secretKey, CIPHER_TRANSFORMATION); + } + + private SecretKey resolveSecretKey(String keyAlias) throws CertificateException, NoSuchAlgorithmException, IOException, UnrecoverableEntryException, KeyStoreException, NoSuchProviderException, InvalidAlgorithmParameterException { + SecretKey secretKey = loadExistingKey(keyAlias); + if (secretKey != null) { + return secretKey; + } + + return generateKey(keyAlias); + } + + private @Nullable SecretKey loadExistingKey(String keyAlias) throws CertificateException, + NoSuchAlgorithmException, + IOException, + UnrecoverableEntryException, + KeyStoreException { + KeyStore keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER); + keyStore.load(null); + KeyStore.SecretKeyEntry secretKeyEntry = ObjectUtils.as(keyStore.getEntry(keyAlias, null), KeyStore.SecretKeyEntry.class); + return secretKeyEntry != null ? secretKeyEntry.getSecretKey() : null; + } + + private SecretKey generateKey(String keyAlias) throws NoSuchProviderException, + NoSuchAlgorithmException, + InvalidAlgorithmParameterException { + KeyGenerator keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER); + keyGenerator.init(new KeyGenParameterSpec.Builder(keyAlias, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setRandomizedEncryptionRequired(false) // we need that to make our custom IV work + .build()); + + return keyGenerator.generateKey(); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver26.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver26.java new file mode 100644 index 000000000..2bdcbf074 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolver26.java @@ -0,0 +1,4 @@ +package com.apptentive.android.sdk.encryption.resolvers; + +class KeyResolver26 extends KeyResolver18 { +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverFactory.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverFactory.java new file mode 100644 index 000000000..37dbacf0f --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverFactory.java @@ -0,0 +1,24 @@ +package com.apptentive.android.sdk.encryption.resolvers; + +import android.os.Build; +import android.support.annotation.NonNull; + +public class KeyResolverFactory { + public static @NonNull KeyResolver createKeyResolver(int versionCode) { + // Android API level 26 has a bug when symmetric key does not work. We use legacy approach instead. + // see: https://stackoverflow.com/questions/36015194/android-keystoreexception-unknown-error + if (versionCode == Build.VERSION_CODES.O) { + return new KeyResolver26(); + } + + if (versionCode >= Build.VERSION_CODES.M) { + return new KeyResolver23(); + } + + if (versionCode >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + return new KeyResolver18(); + } + + return new KeyResolverNoOp(); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverNoOp.java b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverNoOp.java new file mode 100644 index 000000000..d6954f9a9 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/encryption/resolvers/KeyResolverNoOp.java @@ -0,0 +1,13 @@ +package com.apptentive.android.sdk.encryption.resolvers; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.apptentive.android.sdk.encryption.EncryptionKey; + +class KeyResolverNoOp implements KeyResolver { + @NonNull @Override + public EncryptionKey resolveKey(Context context, String keyAlias) { + return EncryptionKey.NULL; // FIXME: descriptive log message + } +} 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 index 87abd4c81..d12ef74b0 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/CompoundMessage.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/CompoundMessage.java @@ -8,6 +8,7 @@ import com.apptentive.android.sdk.ApptentiveInternal; import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.encryption.Encryptor; import com.apptentive.android.sdk.module.messagecenter.model.MessageCenterUtil; import com.apptentive.android.sdk.network.HttpRequestMethod; @@ -86,7 +87,7 @@ public HttpRequestMethod getHttpRequestMethod() { @Override public String getHttpRequestContentType() { - return String.format("%s;boundary=%s", encryptionKey != null ? "multipart/encrypted" : "multipart/mixed", boundary); + return String.format("%s;boundary=%s", isAuthenticated() ? "multipart/encrypted" : "multipart/mixed", boundary); } //endregion @@ -291,11 +292,7 @@ public int getListItemType() { @Override public byte[] renderData() { try { - boolean encrypted = encryptionKey != null; - Encryptor encryptor = null; - if (encrypted) { - encryptor = new Encryptor(encryptionKey); - } + boolean shouldEncrypt = isAuthenticated(); ByteArrayOutputStream data = new ByteArrayOutputStream(); // First write the message body out as the first "part". @@ -310,13 +307,14 @@ public byte[] renderData() { .append(marshallForSending().toString()).append(lineEnd); byte[] partBytes = part.toString().getBytes(); - if (encrypted) { + final EncryptionKey encryptionKey = getEncryptionKey(); + if (shouldEncrypt) { 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(Encryptor.encrypt(encryptionKey, partBytes)); data.write("\r\n".getBytes()); } else { data.write(header.toString().getBytes()); @@ -353,7 +351,7 @@ public byte[] renderData() { Util.ensureClosed(fileInputStream); } - if (encrypted) { + if (shouldEncrypt) { // If encrypted, each part must be encrypted, and wrapped in a plain text set of headers. StringBuilder encryptionEnvelope = new StringBuilder(); encryptionEnvelope @@ -363,7 +361,7 @@ public byte[] renderData() { 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()); + byte[] encryptedAttachment = Encryptor.encrypt(encryptionKey, attachmentBytes.toByteArray()); ApptentiveLog.v(PAYLOADS, "Writing encrypted attachment bytes: %d", encryptedAttachment.length); data.write(encryptedAttachment); } else { 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 index fe2b0948f..ce577a3f8 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/JsonPayload.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/JsonPayload.java @@ -52,11 +52,11 @@ public byte[] renderData() throws JSONException { String jsonString = marshallForSending().toString(); ApptentiveLog.v(PAYLOADS, jsonString); - if (encryptionKey != null) { + // authenticated payloads get encrypted before sending + if (isAuthenticated()) { byte[] bytes = jsonString.getBytes(); - Encryptor encryptor = new Encryptor(encryptionKey); try { - return encryptor.encrypt(bytes); + return Encryptor.encrypt(getEncryptionKey(), bytes); } catch (Exception e) { ApptentiveLog.e(PAYLOADS, "Error encrypting payload data", e); } @@ -206,7 +206,7 @@ public HttpRequestMethod getHttpRequestMethod() { @Override public String getHttpRequestContentType() { - if (encryptionKey != null) { + if (isAuthenticated()) { return "application/octet-stream"; } else { return "application/json"; @@ -235,8 +235,8 @@ protected final JSONObject marshallForSending() throws JSONException { result = jsonObject; } - if (encryptionKey != null) { - result.put("token", token); + if (isAuthenticated()) { + result.put("token", getConversationToken()); } return result; 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 33b24b5e8..2c1a3e447 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 @@ -6,8 +6,11 @@ package com.apptentive.android.sdk.model; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.network.HttpRequestMethod; -import com.apptentive.android.sdk.util.StringUtils; import org.json.JSONException; @@ -17,25 +20,30 @@ public abstract class Payload { private final PayloadType payloadType; /** - * If set, this payload should be encrypted in renderData(). + * Encryption key for encrypting payload. */ - protected String encryptionKey; + private @NonNull EncryptionKey encryptionKey; /** * The Conversation ID of the payload, if known at this time. */ - protected String conversationId; + private 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; + private String token; private String localConversationIdentifier; private List attachments; // TODO: Figure out attachment handling + /** + * true if payload belongs to an authenticated (logged-in) conversation + */ + private boolean authenticated; + protected Payload(PayloadType type) { if (type == null) { throw new IllegalArgumentException("Payload type is null"); @@ -78,12 +86,15 @@ public PayloadType getPayloadType() { return payloadType; } - public void setEncryptionKey(String encryptionKey) { - this.encryptionKey = encryptionKey; + @NonNull EncryptionKey getEncryptionKey() { + return encryptionKey; } - public boolean hasEncryptionKey() { - return !StringUtils.isNullOrEmpty(encryptionKey); + public void setEncryptionKey(@NonNull EncryptionKey encryptionKey) { + if (encryptionKey == null) { + throw new IllegalArgumentException("Encryption key is null"); + } + this.encryptionKey = encryptionKey; } public String getConversationId() { @@ -94,11 +105,11 @@ public void setConversationId(String conversationId) { this.conversationId = conversationId; } - public String getToken() { + public @Nullable String getConversationToken() { return token; } - public void setToken(String token) { + public void setToken(@Nullable String token) { this.token = token; } @@ -122,5 +133,13 @@ public void setLocalConversationIdentifier(String localConversationIdentifier) { this.localConversationIdentifier = localConversationIdentifier; } + public boolean isAuthenticated() { + return authenticated; + } + + public void setAuthenticated(boolean authenticated) { + this.authenticated = authenticated; + } + //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 index cd97db635..41eb391c6 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadData.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/model/PayloadData.java @@ -20,10 +20,10 @@ public class PayloadData { private final String contentType; private final String httpRequestPath; private final HttpRequestMethod httpRequestMethod; - private final boolean encrypted; + private final boolean authenticated; - public PayloadData(PayloadType type, String nonce, String conversationId, byte[] data, String authToken, String contentType, String httpRequestPath, HttpRequestMethod httpRequestMethod, boolean encrypted) { + public PayloadData(PayloadType type, String nonce, String conversationId, byte[] data, String authToken, String contentType, String httpRequestPath, HttpRequestMethod httpRequestMethod, boolean authenticated) { if (type == null) { throw new IllegalArgumentException("Payload type is null"); } @@ -60,7 +60,7 @@ public PayloadData(PayloadType type, String nonce, String conversationId, byte[] this.contentType = contentType; this.httpRequestPath = httpRequestPath; this.httpRequestMethod = httpRequestMethod; - this.encrypted = encrypted; + this.authenticated = authenticated; } //region String representation @@ -106,8 +106,8 @@ public HttpRequestMethod getHttpRequestMethod() { return httpRequestMethod; } - public boolean isEncrypted() { - return encrypted; + public boolean isAuthenticated() { + return authenticated; } //endregion 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 index 2a6d1e597..c468ff173 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/serialization/ObjectSerialization.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/serialization/ObjectSerialization.java @@ -1,9 +1,14 @@ package com.apptentive.android.sdk.serialization; +import android.support.annotation.NonNull; import android.support.v4.util.AtomicFile; +import com.apptentive.android.sdk.encryption.EncryptionKey; +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.DataInput; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -34,6 +39,27 @@ public static void serialize(File file, SerializableObject object) throws IOExce } } + /** + * Writes an object ot an encrypted file + */ + public static void serialize(File file, SerializableObject object, @NonNull EncryptionKey encryptionKey) throws IOException { + ByteArrayOutputStream bos = null; + DataOutputStream dos = null; + try { + bos = new ByteArrayOutputStream(); + dos = new DataOutputStream(bos); + object.writeExternal(dos); + final byte[] unencryptedBytes = bos.toByteArray(); + final byte[] encryptedBytes = Encryptor.encrypt(encryptionKey, unencryptedBytes); + Util.writeAtomically(file, encryptedBytes); + } catch (Exception e) { + throw new IOException(e); + } finally { + Util.ensureClosed(bos); + Util.ensureClosed(dos); + } + } + /** * Reads an object from a file */ @@ -54,4 +80,24 @@ public static T deserialize(File file, Class c Util.ensureClosed(stream); } } + + public static T deserialize(File file, Class cls, EncryptionKey encryptionKey) throws IOException { + try { + final byte[] encryptedBytes = Util.readBytes(file); + final byte[] unencryptedBytes = Encryptor.decrypt(encryptionKey, encryptedBytes); + + ByteArrayInputStream stream = null; + try { + stream = new ByteArrayInputStream(unencryptedBytes); + DataInputStream in = new DataInputStream(stream); + Constructor constructor = cls.getDeclaredConstructor(DataInput.class); + constructor.setAccessible(true); + return constructor.newInstance(in); + } finally { + Util.ensureClosed(stream); + } + } catch (Exception e) { + throw new IOException("Unable to instantiate class: " + cls, e); + } + } } 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 e0f38299e..6971152eb 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 @@ -12,40 +12,38 @@ import android.database.SQLException; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; -import android.text.TextUtils; +import android.support.annotation.Nullable; import com.apptentive.android.sdk.ApptentiveLog; -import com.apptentive.android.sdk.conversation.Conversation; -import com.apptentive.android.sdk.conversation.ConversationDispatchTask; -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.encryption.EncryptionKey; +import com.apptentive.android.sdk.encryption.Encryptor; 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.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 org.json.JSONException; -import org.json.JSONObject; - import java.io.File; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.List; -import java.util.UUID; -import static com.apptentive.android.sdk.ApptentiveHelper.dispatchConversationTask; +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + import static com.apptentive.android.sdk.ApptentiveLog.hideIfSanitized; +import static com.apptentive.android.sdk.ApptentiveLogTag.CONVERSATION; import static com.apptentive.android.sdk.ApptentiveLogTag.DATABASE; -import static com.apptentive.android.sdk.ApptentiveLogTag.MESSAGES; import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; +import static com.apptentive.android.sdk.debug.Assert.assertFail; 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; @@ -54,13 +52,14 @@ */ public class ApptentiveDatabaseHelper extends SQLiteOpenHelper { - private static final int DATABASE_VERSION = 3; + private static final int DATABASE_VERSION = 4; 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; + private final EncryptionKey encryptionKey; //region Payload SQL @@ -74,45 +73,33 @@ static final class PayloadEntry { 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_AUTHENTICATED = new DatabaseColumn(8, "authenticated"); static final DatabaseColumn COLUMN_LOCAL_CONVERSATION_ID = new DatabaseColumn(9, "localConversationId"); } - 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 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 = + static final String SQL_CREATE_PAYLOAD_TABLE = "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_AUTH_TOKEN + " BLOB," + PayloadEntry.COLUMN_CONVERSATION_ID + " TEXT," + PayloadEntry.COLUMN_REQUEST_METHOD + " TEXT," + PayloadEntry.COLUMN_PATH + " TEXT," + - PayloadEntry.COLUMN_ENCRYPTED + " INTEGER," + + PayloadEntry.COLUMN_AUTHENTICATED + " 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; + static final String SQL_DELETE_PAYLOAD_TABLE = "DROP TABLE " + PayloadEntry.TABLE_NAME + ";"; - private static final String SQL_QUERY_PAYLOAD_GET_IN_SEND_ORDER = + private static final String SQL_SELECT_PAYLOADS_IN_SEND_ORDER = "SELECT * FROM " + PayloadEntry.TABLE_NAME + " ORDER BY " + PayloadEntry.COLUMN_PRIMARY_KEY + " ASC"; - private static final String SQL_QUERY_UPDATE_INCOMPLETE_PAYLOADS = + private static final String SQL_UPDATE_INCOMPLETE_PAYLOADS = "UPDATE " + PayloadEntry.TABLE_NAME + " SET " + PayloadEntry.COLUMN_AUTH_TOKEN + " = ?, " + PayloadEntry.COLUMN_CONVERSATION_ID + " = ? " + @@ -121,7 +108,7 @@ private static final class LegacyPayloadEntry { PayloadEntry.COLUMN_AUTH_TOKEN + " IS NULL AND " + PayloadEntry.COLUMN_CONVERSATION_ID + " IS NULL"; - private static final String SQL_QUERY_UPDATE_LEGACY_PAYLOADS = + private static final String SQL_UPDATE_LEGACY_PAYLOADS = "UPDATE " + PayloadEntry.TABLE_NAME + " SET " + PayloadEntry.COLUMN_AUTH_TOKEN + " = ?, " + PayloadEntry.COLUMN_CONVERSATION_ID + " = ?, " + @@ -130,18 +117,12 @@ private static final class LegacyPayloadEntry { PayloadEntry.COLUMN_AUTH_TOKEN + " IS NULL AND " + PayloadEntry.COLUMN_CONVERSATION_ID + " IS NULL"; - private static final String SQL_QUERY_REMOVE_INCOMPLETE_PAYLOADS = + private static final String SQL_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) @@ -167,9 +148,6 @@ private static final class LegacyPayloadEntry { 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"; - //endregion //region File SQL (Deprecated: Used for migration only) @@ -224,10 +202,15 @@ private static final class LegacyPayloadEntry { // endregion - ApptentiveDatabaseHelper(Context context) { + ApptentiveDatabaseHelper(Context context, EncryptionKey encryptionKey) { super(context, DATABASE_NAME, null, DATABASE_VERSION); + if (encryptionKey == null) { + throw new IllegalArgumentException("Encryption key is null"); + } + this.fileDir = context.getFilesDir(); this.payloadDataDir = new File(fileDir, Constants.PAYLOAD_DATA_DIR); + this.encryptionKey = encryptionKey; } //region Create & Upgrade @@ -239,7 +222,7 @@ private static final class LegacyPayloadEntry { @Override public void onCreate(SQLiteDatabase db) { ApptentiveLog.d(DATABASE, "ApptentiveDatabase.onCreate(db)"); - db.execSQL(TABLE_CREATE_PAYLOAD); + db.execSQL(SQL_CREATE_PAYLOAD_TABLE); // Leave legacy tables in place for now. db.execSQL(TABLE_CREATE_MESSAGE); @@ -253,276 +236,34 @@ public void onCreate(SQLiteDatabase db) { */ @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - ApptentiveLog.d(DATABASE, "ApptentiveDatabase.onUpgrade(db, %d, %d)", oldVersion, newVersion); - switch (oldVersion) { - case 1: - upgradeVersion1to2(db); - case 2: - upgradeVersion2to3(db); - } - } - - private void upgradeVersion1to2(SQLiteDatabase db) { - Cursor cursor = null; - // Migrate legacy stored files to compound message associated files + ApptentiveLog.d(DATABASE, "Upgrade database from %d to %d", oldVersion, newVersion); try { - cursor = db.rawQuery("SELECT * FROM " + TABLE_FILESTORE, null); - if (cursor.moveToFirst()) { - do { - String file_nonce = cursor.getString(0); - // Stored File id was in the format of "apptentive-file-nonce" - String patten = "apptentive-file-"; - String nonce = file_nonce.substring(file_nonce.indexOf(patten) + patten.length()); - ContentValues values = new ContentValues(); - values.put(COMPOUND_FILESTORE_KEY_MESSAGE_NONCE, nonce); - // Legacy file was stored in db by name only. Need to get the full path when migrated - String localFileName = cursor.getString(3); - values.put(COMPOUND_FILESTORE_KEY_LOCAL_CACHE_PATH, (new File(fileDir, localFileName).getAbsolutePath())); - values.put(COMPOUND_FILESTORE_KEY_MIME_TYPE, cursor.getString(1)); - // Original file name might not be stored, i.e. sent by API, in which case, local stored file name will be used. - String originalFileName = cursor.getString(2); - if (TextUtils.isEmpty(originalFileName)) { - originalFileName = localFileName; - } - values.put(COMPOUND_FILESTORE_KEY_LOCAL_ORIGINAL_URI, originalFileName); - values.put(COMPOUND_FILESTORE_KEY_REMOTE_URL, cursor.getString(4)); - values.put(COMPOUND_FILESTORE_KEY_CREATION_TIME, 0); // we didn't store creation time of legacy file message - db.insert(TABLE_COMPOUND_MESSAGE_FILESTORE, null, values); - - } while (cursor.moveToNext()); + DatabaseMigrator migrator = createDatabaseMigrator(oldVersion, newVersion); + if (migrator != null) { + migrator.onUpgrade(db, oldVersion, newVersion); } - } catch (SQLException sqe) { - ApptentiveLog.e(DATABASE, "migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); - } finally { - ensureClosed(cursor); - } - // Migrate legacy message types to CompoundMessage Type - try { - cursor = db.rawQuery(QUERY_MESSAGE_GET_ALL_IN_ORDER, null); - if (cursor.moveToFirst()) { - do { - String json = cursor.getString(6); - JSONObject root; - boolean bUpdateRecord = false; - try { - root = new JSONObject(json); - ApptentiveMessage.Type type = ApptentiveMessage.Type.valueOf(root.getString(ApptentiveMessage.KEY_TYPE)); - switch (type) { - case TextMessage: - root.put(ApptentiveMessage.KEY_TYPE, ApptentiveMessage.Type.CompoundMessage.name()); - root.put(CompoundMessage.KEY_TEXT_ONLY, true); - bUpdateRecord = true; - break; - case FileMessage: - root.put(ApptentiveMessage.KEY_TYPE, ApptentiveMessage.Type.CompoundMessage.name()); - root.put(CompoundMessage.KEY_TEXT_ONLY, false); - bUpdateRecord = true; - break; - case AutomatedMessage: - root.put(ApptentiveMessage.KEY_TYPE, ApptentiveMessage.Type.CompoundMessage.name()); - root.put(CompoundMessage.KEY_TEXT_ONLY, true); - root.put(ApptentiveMessage.KEY_AUTOMATED, true); - bUpdateRecord = true; - break; - default: - break; - } - if (bUpdateRecord) { - String databaseId = cursor.getString(0); - ContentValues messageValues = new ContentValues(); - messageValues.put(MESSAGE_KEY_JSON, root.toString()); - db.update(TABLE_MESSAGE, messageValues, MESSAGE_KEY_DB_ID + " = ?", new String[]{databaseId}); - } - } catch (JSONException e) { - ApptentiveLog.v(DATABASE, "Error parsing json as Message: %s", e, json); - } - } while (cursor.moveToNext()); - } - } catch (SQLException sqe) { - ApptentiveLog.e(DATABASE, "migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); - } finally { - ensureClosed(cursor); - } - - // Migrate all pending payload messages - // Migrate legacy message types to CompoundMessage Type - try { - 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); - JSONObject root; - boolean bUpdateRecord = false; - try { - root = new JSONObject(json); - ApptentiveMessage.Type type = ApptentiveMessage.Type.valueOf(root.getString(ApptentiveMessage.KEY_TYPE)); - switch (type) { - case TextMessage: - root.put(ApptentiveMessage.KEY_TYPE, ApptentiveMessage.Type.CompoundMessage.name()); - root.put(CompoundMessage.KEY_TEXT_ONLY, true); - bUpdateRecord = true; - break; - case FileMessage: - root.put(ApptentiveMessage.KEY_TYPE, ApptentiveMessage.Type.CompoundMessage.name()); - root.put(CompoundMessage.KEY_TEXT_ONLY, false); - bUpdateRecord = true; - break; - case AutomatedMessage: - root.put(ApptentiveMessage.KEY_TYPE, ApptentiveMessage.Type.CompoundMessage.name()); - root.put(CompoundMessage.KEY_TEXT_ONLY, true); - root.put(ApptentiveMessage.KEY_AUTOMATED, true); - bUpdateRecord = true; - break; - default: - break; - } - if (bUpdateRecord) { - String databaseId = cursor.getString(LegacyPayloadEntry.PAYLOAD_KEY_DB_ID.index); - ContentValues messageValues = new ContentValues(); - 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(DATABASE, "Error parsing json as Message: %s", e, json); - } - } while (cursor.moveToNext()); - } - } catch (SQLException sqe) { - ApptentiveLog.e(DATABASE, "migrateToCompoundMessage EXCEPTION: " + sqe.getMessage()); - } finally { - ensureClosed(cursor); - } - } - - /** - * 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.v(DATABASE, "\t1. Backing up \"payloads\" database to \"legacy_payloads\""); - db.execSQL(BACKUP_LEGACY_PAYLOAD_TABLE); - - // 2. Create new Payload table as "payload" - ApptentiveLog.v(DATABASE, "\t2. Creating new \"payloads\" database."); - db.execSQL(TABLE_CREATE_PAYLOAD); - - // 3. Load legacy payloads - ApptentiveLog.v(DATABASE, "\t3. Loading legacy payloads."); - cursor = db.rawQuery(SQL_QUERY_PAYLOAD_LIST_LEGACY, null); - - ApptentiveLog.v(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.v(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", hideIfSanitized(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.v(DATABASE, "\t6. Migrating messages."); - migrateMessages(db); - - // 6. Finally, delete the temporary legacy table - ApptentiveLog.v(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(); - } - } - } + ApptentiveLog.e(DATABASE, e, "Exception while trying to migrate database from %d to %d", oldVersion, newVersion); - private void migrateMessages(SQLiteDatabase db) { - try { - final List messages = getAllMessages(db); - dispatchConversationTask(new ConversationDispatchTask() { - @Override - protected boolean execute(Conversation conversation) { - conversation.getMessageManager().addMessages(messages.toArray(new ApptentiveMessage[messages.size()])); - return true; - } - }, "migrate messages"); - } catch (Exception e) { - ApptentiveLog.e(e, "Exception while trying to migrate messages"); + // if migration failed - create new table + db.execSQL(SQL_DELETE_PAYLOAD_TABLE); + onCreate(db); } } - 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(MESSAGES, "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); + private @Nullable DatabaseMigrator createDatabaseMigrator(int oldVersion, int newVersion) { + switch (oldVersion) { + case 1: + return new DatabaseMigratorV1(encryptionKey, payloadDataDir); + case 2: + return new DatabaseMigratorV2(encryptionKey, payloadDataDir); + case 3: + return new DatabaseMigratorV3(encryptionKey, payloadDataDir); } - return messages; - } + assertFail("Missing database migrator version: %d", oldVersion); + return null; + } //endregion @@ -542,9 +283,9 @@ void addPayload(Payload payload) { 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 + // The token is encrypted inside the payload body for authenticated conversations. In that case, don't store it here. + if (!payload.isAuthenticated()) { + values.put(PayloadEntry.COLUMN_AUTH_TOKEN.name, encrypt(payload.getConversationToken())); // 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()); @@ -553,10 +294,10 @@ void addPayload(Payload payload) { ); File dest = getPayloadBodyFile(payload.getNonce()); - ApptentiveLog.v(DATABASE, "Saving payload body to: %s", hideIfSanitized(dest)); - Util.writeBytes(dest, payload.renderData()); + ApptentiveLog.v(DATABASE, "Saving payload body to: %s", dest); + writeToFile(dest, payload.renderData(), !payload.isAuthenticated()); // only anonymous payloads get encrypted upon write (authenticated payloads get encrypted on serialization) - values.put(PayloadEntry.COLUMN_ENCRYPTED.name, payload.hasEncryptionKey() ? TRUE : FALSE); + values.put(PayloadEntry.COLUMN_AUTHENTICATED.name, payload.isAuthenticated() ? TRUE : FALSE); values.put(PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID.name, notNull(payload.getLocalConversationIdentifier())); db.insert(PayloadEntry.TABLE_NAME, null, values); @@ -620,7 +361,7 @@ PayloadData getOldestUnsentPayload() { Cursor cursor = null; try { db = getWritableDatabase(); - cursor = db.rawQuery(SQL_QUERY_PAYLOAD_GET_IN_SEND_ORDER, null); + cursor = db.rawQuery(SQL_SELECT_PAYLOADS_IN_SEND_ORDER, null); int count = cursor.getCount(); ApptentiveLog.v(PAYLOADS, "Unsent payloads count: %d", count); @@ -631,7 +372,7 @@ PayloadData getOldestUnsentPayload() { return null; } - final String authToken = cursor.getString(PayloadEntry.COLUMN_AUTH_TOKEN.index); + final String authToken = decryptString(cursor.getBlob(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)); @@ -652,11 +393,12 @@ PayloadData getOldestUnsentPayload() { 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); + final boolean authenticated = cursor.getInt(PayloadEntry.COLUMN_AUTHENTICATED.index) == TRUE; + byte[] data = readFromFile(file, !authenticated); // only anonymous payloads get encrypted upon write (authenticated payloads get encrypted on serialization) + return new PayloadData(payloadType, nonce, conversationId, data, authToken, contentType, httpRequestPath, httpRequestMethod, authenticated); } return null; } catch (Exception e) { @@ -682,18 +424,14 @@ void updateIncompletePayloads(String conversationId, String authToken, String lo if (StringUtils.isNullOrEmpty(authToken)) { throw new IllegalArgumentException("Token is null or empty"); } - Cursor cursor = null; try { SQLiteDatabase db = getWritableDatabase(); - cursor = db.rawQuery(legacyPayloads ? SQL_QUERY_UPDATE_LEGACY_PAYLOADS : SQL_QUERY_UPDATE_INCOMPLETE_PAYLOADS, new String[] { - authToken, conversationId, localConversationId + db.execSQL(legacyPayloads ? SQL_UPDATE_LEGACY_PAYLOADS : SQL_UPDATE_INCOMPLETE_PAYLOADS, new Object[] { + encrypt(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) { + } catch (Exception e) { ApptentiveLog.e(e, "Exception while updating missing conversation ids"); - } finally { - ensureClosed(cursor); } // remove incomplete payloads which don't belong to an active conversation @@ -708,7 +446,7 @@ private void removeCorruptedPayloads() { Cursor cursor = null; try { SQLiteDatabase db = getWritableDatabase(); - cursor = db.rawQuery(SQL_QUERY_REMOVE_INCOMPLETE_PAYLOADS, null); + cursor = db.rawQuery(SQL_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) { @@ -822,11 +560,53 @@ void reset(Context context) { context.deleteDatabase(DATABASE_NAME); } + private byte[] encrypt(@Nullable String value) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + return Encryptor.encrypt(encryptionKey, value); + } + + private String decryptString(byte[] bytes) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + return Encryptor.decryptString(encryptionKey, bytes); + } + + private void writeToFile(File file, byte[] data, boolean encrypted) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IOException, + BadPaddingException, + IllegalBlockSizeException, + InvalidAlgorithmParameterException { + if (encrypted) { + Encryptor.writeToEncryptedFile(encryptionKey, file, data); + } else { + Util.writeAtomically(file, data); + } + } + + private byte[] readFromFile(File file, boolean encrypted) throws NoSuchPaddingException, + InvalidAlgorithmParameterException, + NoSuchAlgorithmException, + IOException, + BadPaddingException, + IllegalBlockSizeException, + InvalidKeyException { + return encrypted ? Encryptor.readFromEncryptedFile(encryptionKey, file) : Util.readBytes(file); + } + //endregion //region Helper classes - private static final class DatabaseColumn { + static final class DatabaseColumn { public final String name; final int index; @@ -850,7 +630,7 @@ private void printPayloadTable(String title) { Cursor cursor = null; try { db = getWritableDatabase(); - cursor = db.rawQuery(SQL_QUERY_PAYLOAD_GET_IN_SEND_ORDER, null); + cursor = db.rawQuery(SQL_SELECT_PAYLOADS_IN_SEND_ORDER, null); int payloadCount = cursor.getCount(); if (payloadCount == 0) { ApptentiveLog.v(PAYLOADS, "%s (%d payload(s))", title, payloadCount); @@ -866,14 +646,13 @@ private void printPayloadTable(String title) { PayloadEntry.COLUMN_CONVERSATION_ID, PayloadEntry.COLUMN_REQUEST_METHOD, PayloadEntry.COLUMN_PATH, - PayloadEntry.COLUMN_ENCRYPTED, + PayloadEntry.COLUMN_AUTHENTICATED, 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), @@ -882,14 +661,14 @@ private void printPayloadTable(String title) { cursor.getString(PayloadEntry.COLUMN_CONVERSATION_ID.index), cursor.getString(PayloadEntry.COLUMN_REQUEST_METHOD.index), hideIfSanitized(cursor.getString(PayloadEntry.COLUMN_PATH.index)), - cursor.getInt(PayloadEntry.COLUMN_ENCRYPTED.index), + cursor.getInt(PayloadEntry.COLUMN_AUTHENTICATED.index), cursor.getString(PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID.index), - hideIfSanitized(cursor.getString(PayloadEntry.COLUMN_AUTH_TOKEN.index)) + hideIfSanitized(decryptString(cursor.getBlob(PayloadEntry.COLUMN_AUTH_TOKEN.index))) }; } ApptentiveLog.v(PAYLOADS, "%s (%d payload(s)):\n%s", title, payloadCount, StringUtils.table(rows)); - } catch (Exception ignored) { - ignored.printStackTrace(); + } catch (Exception e) { + ApptentiveLog.e(CONVERSATION, e, "Exception while printing metadata"); } finally { ensureClosed(cursor); } 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 34a281c9c..99ca42141 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 @@ -12,6 +12,7 @@ import com.apptentive.android.sdk.comm.ApptentiveHttpClient; import com.apptentive.android.sdk.conversation.Conversation; import com.apptentive.android.sdk.conversation.ConversationState; +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.model.Payload; import com.apptentive.android.sdk.model.PayloadData; import com.apptentive.android.sdk.model.StoredFile; @@ -64,8 +65,8 @@ public class ApptentiveTaskManager implements PayloadStore, EventStore, Apptenti /* * Creates an asynchronous task manager with one worker thread. This constructor must be invoked on the UI thread. */ - public ApptentiveTaskManager(Context context, ApptentiveHttpClient apptentiveHttpClient) { - dbHelper = new ApptentiveDatabaseHelper(context); + public ApptentiveTaskManager(Context context, ApptentiveHttpClient apptentiveHttpClient, EncryptionKey encryptionKey) { + dbHelper = new ApptentiveDatabaseHelper(context, encryptionKey); /* 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. * 2. If the single worker thread is running with tasks, it queues tasks. diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigrator.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigrator.java new file mode 100644 index 000000000..2f6d9a67d --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigrator.java @@ -0,0 +1,79 @@ +package com.apptentive.android.sdk.storage; + +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.support.annotation.Nullable; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.EncryptionKey; +import com.apptentive.android.sdk.encryption.Encryptor; +import com.apptentive.android.sdk.util.Util; + +import java.io.File; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import static com.apptentive.android.sdk.ApptentiveLogTag.DATABASE; +import static com.apptentive.android.sdk.util.Constants.PAYLOAD_DATA_FILE_SUFFIX; + +abstract class DatabaseMigrator { + static final int TRUE = 1; + static final int FALSE = 0; + + private final EncryptionKey encryptionKey; + private final File payloadDataDir; + + public DatabaseMigrator(EncryptionKey encryptionKey, File payloadDataDir) { + this.encryptionKey = encryptionKey; + this.payloadDataDir = payloadDataDir; + } + + public abstract void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) throws Exception; + + //region Helpers + + protected File getPayloadBodyFile(String nonce) { + return new File(payloadDataDir, nonce + PAYLOAD_DATA_FILE_SUFFIX); + } + + protected byte[] encrypt(@Nullable String value) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidAlgorithmParameterException { + return Encryptor.encrypt(encryptionKey, value); + } + + protected void writeToFile(File file, byte[] data, boolean encrypted) throws NoSuchPaddingException, + InvalidKeyException, + NoSuchAlgorithmException, + IOException, + BadPaddingException, + IllegalBlockSizeException, + InvalidAlgorithmParameterException { + if (encrypted) { + Encryptor.writeToEncryptedFile(encryptionKey, file, data); + } else { + Util.writeAtomically(file, data); + } + } + + void ensureClosed(Cursor cursor) { + try { + if (cursor != null) { + cursor.close(); + } + } catch (Exception e) { + ApptentiveLog.w(DATABASE, "Error closing SQLite cursor.", e); + } + } + + //endregion +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV1.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV1.java new file mode 100644 index 000000000..3c4327e35 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV1.java @@ -0,0 +1,29 @@ +package com.apptentive.android.sdk.storage; + +import android.database.sqlite.SQLiteDatabase; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.EncryptionKey; + +import java.io.File; + +import static com.apptentive.android.sdk.ApptentiveLogTag.DATABASE; +import static com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.SQL_CREATE_PAYLOAD_TABLE; +import static com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.SQL_DELETE_PAYLOAD_TABLE; + +class DatabaseMigratorV1 extends DatabaseMigrator { + public DatabaseMigratorV1(EncryptionKey encryptionKey, File payloadDataDir) { + super(encryptionKey, payloadDataDir); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // 1. Delete old table + ApptentiveLog.v(DATABASE, "\t1. Dropping legacy table..."); + db.execSQL(SQL_DELETE_PAYLOAD_TABLE); + + // 2. Create new table + ApptentiveLog.v(DATABASE, "\t2. Creating new table..."); + db.execSQL(SQL_CREATE_PAYLOAD_TABLE); + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV2.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV2.java new file mode 100644 index 000000000..878855ab9 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV2.java @@ -0,0 +1,179 @@ +package com.apptentive.android.sdk.storage; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.conversation.Conversation; +import com.apptentive.android.sdk.conversation.ConversationDispatchTask; +import com.apptentive.android.sdk.encryption.EncryptionKey; +import com.apptentive.android.sdk.model.ApptentiveMessage; +import com.apptentive.android.sdk.model.JsonPayload; +import com.apptentive.android.sdk.model.PayloadType; +import com.apptentive.android.sdk.module.messagecenter.model.MessageFactory; +import com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.DatabaseColumn; +import com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.PayloadEntry; +import com.apptentive.android.sdk.storage.legacy.LegacyPayloadFactory; +import com.apptentive.android.sdk.util.StringUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static com.apptentive.android.sdk.ApptentiveHelper.dispatchConversationTask; +import static com.apptentive.android.sdk.ApptentiveLog.hideIfSanitized; +import static com.apptentive.android.sdk.ApptentiveLogTag.DATABASE; +import static com.apptentive.android.sdk.ApptentiveLogTag.MESSAGES; +import static com.apptentive.android.sdk.debug.Assert.notNull; +import static com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.SQL_CREATE_PAYLOAD_TABLE; + +class DatabaseMigratorV2 extends DatabaseMigrator { + + + 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 SQL_SELECT_LEGACY_PAYLOADS = "SELECT * FROM " + LegacyPayloadEntry.TABLE_NAME + + " ORDER BY " + LegacyPayloadEntry.PAYLOAD_KEY_DB_ID; + private static final String SQL_SELECT_MESSAGES_IN_ORDER = "SELECT * FROM message ORDER BY COALESCE(id, 'z') ASC"; + private static final String SQL_BACKUP_LEGACY_PAYLOAD_TABLE = String.format("ALTER TABLE %s RENAME TO %s;", PayloadEntry.TABLE_NAME, LegacyPayloadEntry.TABLE_NAME); + private static final String SQL_DELETE_LEGACY_PAYLOAD_TABLE = String.format("DROP TABLE %s;", LegacyPayloadEntry.TABLE_NAME); + + public DatabaseMigratorV2(EncryptionKey encryptionKey, File payloadDataDir) { + super(encryptionKey, payloadDataDir); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + /* + * 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 + */ + Cursor cursor = null; + try { + db.beginTransaction(); + + // 1. Rename existing "payload" table to "legacy_payload" + ApptentiveLog.v(DATABASE, "\t1. Backing up \"payloads\" database to \"legacy_payloads\""); + db.execSQL(SQL_BACKUP_LEGACY_PAYLOAD_TABLE); + + // 2. Create new Payload table as "payload" + ApptentiveLog.v(DATABASE, "\t2. Creating new \"payloads\" database."); + db.execSQL(SQL_CREATE_PAYLOAD_TABLE); + + // 3. Load legacy payloads + ApptentiveLog.v(DATABASE, "\t3. Loading legacy payloads."); + cursor = db.rawQuery(SQL_SELECT_LEGACY_PAYLOADS, null); + + ApptentiveLog.v(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.v(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.isAuthenticated()) { + values.put(PayloadEntry.COLUMN_AUTH_TOKEN.name, encrypt(payload.getConversationToken())); // 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", hideIfSanitized(dest)); + writeToFile(dest, payload.renderData(), !payload.isAuthenticated()); + + values.put(PayloadEntry.COLUMN_AUTHENTICATED.name, payload.isAuthenticated() ? TRUE : FALSE); + + db.insert(PayloadEntry.TABLE_NAME, null, values); + } + + // 5. Migrate messages + ApptentiveLog.v(DATABASE, "\t6. Migrating messages."); + migrateMessages(db); + + // 6. Finally, delete the temporary legacy table + ApptentiveLog.v(DATABASE, "\t6. Delete temporary \"legacy_payloads\" database."); + db.execSQL(SQL_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); + dispatchConversationTask(new ConversationDispatchTask() { + @Override + protected boolean execute(Conversation conversation) { + conversation.getMessageManager().addMessages(messages.toArray(new ApptentiveMessage[messages.size()])); + return true; + } + }, "migrate messages"); + } 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(SQL_SELECT_MESSAGES_IN_ORDER, null); + while (cursor.moveToNext()) { + String json = cursor.getString(6); + ApptentiveMessage message = MessageFactory.fromJson(json); + if (message == null) { + ApptentiveLog.e(MESSAGES, "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; + } +} diff --git a/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV3.java b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV3.java new file mode 100644 index 000000000..b6eee48a6 --- /dev/null +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/DatabaseMigratorV3.java @@ -0,0 +1,132 @@ +package com.apptentive.android.sdk.storage; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import com.apptentive.android.sdk.ApptentiveLog; +import com.apptentive.android.sdk.encryption.EncryptionKey; +import com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.DatabaseColumn; +import com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.PayloadEntry; +import com.apptentive.android.sdk.util.Util; + +import java.io.File; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; + +import static com.apptentive.android.sdk.ApptentiveLogTag.DATABASE; +import static com.apptentive.android.sdk.ApptentiveLogTag.PAYLOADS; +import static com.apptentive.android.sdk.storage.ApptentiveDatabaseHelper.SQL_CREATE_PAYLOAD_TABLE; + +class DatabaseMigratorV3 extends DatabaseMigrator { + private static final String TABLE_NAME_PAYLOADS = "payload"; + private static final String TABLE_NAME_PAYLOADS_LEGACY = "legacy_payload"; + + private static final class PayloadEntryLegacy { + 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"); + } + + private static final String SQL_BACKUP_LEGACY_PAYLOAD_TABLE = String.format("ALTER TABLE %s RENAME TO %s;", TABLE_NAME_PAYLOADS, TABLE_NAME_PAYLOADS_LEGACY); + private static final String SQL_DELETE_LEGACY_PAYLOAD_TABLE = String.format("DROP TABLE %s;", TABLE_NAME_PAYLOADS_LEGACY); + + private static final String SQL_QUERY_SELECT_LEGACY_PAYLOADS = "SELECT * FROM " + TABLE_NAME_PAYLOADS_LEGACY + + " ORDER BY " + PayloadEntryLegacy.COLUMN_PRIMARY_KEY + + " ASC"; + + public DatabaseMigratorV3(EncryptionKey encryptionKey, File payloadDataDir) { + super(encryptionKey, payloadDataDir); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) throws IOException, + NoSuchPaddingException, + InvalidAlgorithmParameterException, + NoSuchAlgorithmException, + IllegalBlockSizeException, + BadPaddingException, + InvalidKeyException { + Cursor cursor = null; + try { + db.beginTransaction(); + + // 1. Rename existing "payload" table to "legacy_payload" + ApptentiveLog.v(DATABASE, "\t1. Backing up '%s' table to '%s'...", TABLE_NAME_PAYLOADS, TABLE_NAME_PAYLOADS_LEGACY); + db.execSQL(SQL_BACKUP_LEGACY_PAYLOAD_TABLE); + + // 2. Create new Payload table as "payload" + ApptentiveLog.v(DATABASE, "\t2. Creating new '%s' table...", TABLE_NAME_PAYLOADS); + db.execSQL(SQL_CREATE_PAYLOAD_TABLE); + + // 3. Load legacy payloads + cursor = db.rawQuery(SQL_QUERY_SELECT_LEGACY_PAYLOADS, null); + ApptentiveLog.v(DATABASE, "\t3. Migrating legacy payloads (%d)...", cursor.getCount()); + + while (cursor.moveToNext()) { + // read legacy payload data + final String nonce = cursor.getString(PayloadEntryLegacy.COLUMN_IDENTIFIER.index); + final String conversationId = cursor.getString(PayloadEntryLegacy.COLUMN_CONVERSATION_ID.index); + final String localConversationId = cursor.getString(PayloadEntryLegacy.COLUMN_LOCAL_CONVERSATION_ID.index); + final String conversationToken = cursor.getString(PayloadEntryLegacy.COLUMN_AUTH_TOKEN.index); + final String httpRequestPath = cursor.getString(PayloadEntryLegacy.COLUMN_PATH.index); + final String contentType = cursor.getString(PayloadEntryLegacy.COLUMN_CONTENT_TYPE.index); + final String httpRequestMethod = cursor.getString(PayloadEntryLegacy.COLUMN_REQUEST_METHOD.index); + final int authenticated = cursor.getInt(PayloadEntryLegacy.COLUMN_ENCRYPTED.index); + final String payloadType = cursor.getString(PayloadEntryLegacy.COLUMN_PAYLOAD_TYPE.index); + + // check for inconsistency + File file = getPayloadBodyFile(nonce); + if (!file.exists()) { + ApptentiveLog.w(PAYLOADS, "\t\tLegacy payload missing its data file. Skipping..."); + continue; + } + + // encrypt payload body + File payloadFile = getPayloadBodyFile(nonce); + final boolean shouldWriteEncrypted = authenticated == FALSE; // anonymous payloads should we stored encrypted + writeToFile(payloadFile, Util.readBytes(payloadFile), shouldWriteEncrypted); + + // FIXME: remove code duplication + + // insert payload data into the new table + ContentValues values = new ContentValues(); + values.put(PayloadEntry.COLUMN_IDENTIFIER.name, nonce); + values.put(PayloadEntry.COLUMN_CONVERSATION_ID.name, conversationId); + values.put(PayloadEntry.COLUMN_LOCAL_CONVERSATION_ID.name, localConversationId); + values.put(PayloadEntry.COLUMN_PAYLOAD_TYPE.name, payloadType); + values.put(PayloadEntry.COLUMN_CONTENT_TYPE.name, contentType); + values.put(PayloadEntry.COLUMN_AUTH_TOKEN.name, encrypt(conversationToken)); // token gets encrypted + values.put(PayloadEntry.COLUMN_REQUEST_METHOD.name, httpRequestMethod); + values.put(PayloadEntry.COLUMN_PATH.name, httpRequestPath); + values.put(PayloadEntry.COLUMN_AUTHENTICATED.name, authenticated); + + db.insert(TABLE_NAME_PAYLOADS, null, values); + } + + // 5. Finally, delete the temporary legacy table + ApptentiveLog.v(DATABASE, "\t6. Dropping temporary '%s' table...", TABLE_NAME_PAYLOADS_LEGACY); + db.execSQL(SQL_DELETE_LEGACY_PAYLOAD_TABLE); + + db.setTransactionSuccessful(); + } finally { + ensureClosed(cursor); + if (db != null) { + db.endTransaction(); + } + } + } +} \ No newline at end of file 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 index 8db840768..84e63976f 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/storage/EncryptedFileSerializer.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/storage/EncryptedFileSerializer.java @@ -6,6 +6,7 @@ package com.apptentive.android.sdk.storage; +import com.apptentive.android.sdk.encryption.EncryptionKey; import com.apptentive.android.sdk.encryption.Encryptor; import com.apptentive.android.sdk.util.Util; @@ -13,18 +14,17 @@ import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; -import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; public class EncryptedFileSerializer extends FileSerializer { - private final String encryptionKey; + private final EncryptionKey encryptionKey; - public EncryptedFileSerializer(File file, String encryptionKey) { + public EncryptedFileSerializer(File file, EncryptionKey encryptionKey) { super(file); if (encryptionKey == null) { - throw new IllegalArgumentException("'encryptionKey' is null"); + throw new IllegalArgumentException("'encryptionKey' is null or empty"); } this.encryptionKey = encryptionKey; @@ -39,8 +39,7 @@ protected void serialize(FileOutputStream stream, Object object) throws Exceptio oos = new ObjectOutputStream(bos); oos.writeObject(object); final byte[] unencryptedBytes = bos.toByteArray(); - Encryptor encryptor = new Encryptor(encryptionKey); - final byte[] encryptedBytes = encryptor.encrypt(unencryptedBytes); + final byte[] encryptedBytes = Encryptor.encrypt(encryptionKey, unencryptedBytes); stream.write(encryptedBytes); // TODO: should we write using a buffer? } finally { Util.ensureClosed(bos); @@ -52,8 +51,7 @@ protected void serialize(FileOutputStream stream, Object object) throws Exceptio 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); + final byte[] unencryptedBytes = Encryptor.decrypt(encryptionKey, encryptedBytes); ByteArrayInputStream bis = null; ObjectInputStream ois = null; 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 e671a2acf..483338db8 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 @@ -9,7 +9,7 @@ public class Constants { public static final int API_VERSION = 9; - private static final String APPTENTIVE_SDK_VERSION = "5.2.0"; + private static final String APPTENTIVE_SDK_VERSION = "5.3.0"; public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 45000; public static final int DEFAULT_READ_TIMEOUT_MILLIS = 45000; @@ -64,7 +64,8 @@ public class Constants { //region Database and File Storage public static final String CONVERSATIONS_DIR = "apptentive/conversations"; - public static final String CONVERSATION_METADATA_FILE = "conversation-v1.meta"; + public static final String CONVERSATION_METADATA_FILE = "conversation-v2.meta"; + public static final String CONVERSATION_METADATA_FILE_LEGACY_V1 = "conversation-v1.meta"; public static final String PAYLOAD_DATA_DIR = "payloads"; public static final String PAYLOAD_DATA_FILE_SUFFIX = ".data"; //endregion 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 45a9c6642..09e266a0e 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 @@ -21,6 +21,8 @@ package com.apptentive.android.sdk.util; +import android.support.annotation.Nullable; + import java.util.HashMap; import java.util.Map; @@ -33,7 +35,7 @@ public final class ObjectUtils { * Returns null if cast is impossible. */ @SuppressWarnings("unchecked") - public static T as(Object object, Class cls) { + public static @Nullable T as(Object object, Class cls) { return cls.isInstance(object) ? (T) object : null; } @@ -50,7 +52,7 @@ public static Map toMap(Object... args) { return map; } - public static boolean equal(Object expected, Object actual) { + public static boolean equal(@Nullable Object expected, @Nullable 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 index 9417a2dcd..2ca6e44fe 100644 --- a/apptentive/src/main/java/com/apptentive/android/sdk/util/RuntimeUtils.java +++ b/apptentive/src/main/java/com/apptentive/android/sdk/util/RuntimeUtils.java @@ -25,8 +25,7 @@ public class RuntimeUtils { private static ApplicationInfo cachedApplicationInfo; - public synchronized static @NonNull - ApplicationInfo getApplicationInfo(Context context) { + public synchronized static @NonNull ApplicationInfo getApplicationInfo(Context context) { if (context == null) { throw new IllegalArgumentException("Context is null"); } 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 310ebd472..83c9c176d 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,7 +25,6 @@ import org.json.JSONObject; -import java.net.URLEncoder; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Date; @@ -124,43 +123,6 @@ 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) { - 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 */ @@ -208,6 +170,8 @@ public static byte[] hexToBytes(String hex) { return ret; } + //region Pretty print + public static String table(Object[][] rows) { return table(rows, null); } @@ -248,6 +212,8 @@ public static String table(Object[][] rows, String title) { return result.toString(); } + //endregion + //region Parsing public static int parseInt(String value, int defaultValue) { 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 52bf5774f..12131c538 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 @@ -40,6 +40,7 @@ import android.support.annotation.Nullable; import android.support.v4.content.ContextCompat; import android.support.v4.content.IntentCompat; +import android.support.v4.util.AtomicFile; import android.text.TextUtils; import android.util.Patterns; import android.util.TypedValue; @@ -87,6 +88,8 @@ // TODO: this class does too much - split into smaller classes and clean up public class Util { + private static final String ENCRYPTED_FILENAME_SUFFIX = ".encrypted"; + public static int getStatusBarHeight(Window window) { Rect rectangle = new Rect(); window.getDecorView().getWindowVisibleDisplayFrame(rectangle); @@ -823,6 +826,22 @@ public static void appendFileToStream(File file, OutputStream outputStream) thro } } + /** + * Performs an 'atomic' write to a file (to avoid data corruption) + */ + public static void writeAtomically(File file, byte[] bytes) throws IOException { + AtomicFile atomicFile = new AtomicFile(file); + FileOutputStream stream = null; + try { + stream = atomicFile.startWrite(); + stream.write(bytes); + atomicFile.finishWrite(stream); // serialization was successful + } catch (Exception e) { + atomicFile.failWrite(stream); // serialization failed + throw new IOException(e); // throw exception up the chain + } + } + private static void copy(InputStream input, OutputStream output) throws IOException { byte[] buffer = new byte[4096]; int bytesRead; @@ -831,7 +850,7 @@ private static void copy(InputStream input, OutputStream output) throws IOExcept } } - public static void writeNullableUTF(DataOutput out, String value) throws IOException { + public static void writeNullableUTF(DataOutput out, @Nullable String value) throws IOException { out.writeBoolean(value != null); if (value != null) { out.writeUTF(value); @@ -1213,4 +1232,14 @@ private static Intent makeRestartActivityTaskGuarded(ComponentName cn) { return IntentCompat.makeRestartActivityTask(cn); } } + + public static File getEncryptedFilename(File file) { + String filename = file.getName(); + return filename.endsWith(ENCRYPTED_FILENAME_SUFFIX) ? file : new File(file.getParent(), filename + ENCRYPTED_FILENAME_SUFFIX); + } + + public static File getUnencryptedFilename(File file) { + String filename = file.getName(); + return filename.endsWith(ENCRYPTED_FILENAME_SUFFIX) ? new File(file.getParent(), filename.substring(0, filename.length() - ENCRYPTED_FILENAME_SUFFIX.length())) : file; + } } diff --git a/apptentive/src/test/java/com/apptentive/android/sdk/util/UtilTest.java b/apptentive/src/test/java/com/apptentive/android/sdk/util/UtilTest.java new file mode 100644 index 000000000..6a4adbd76 --- /dev/null +++ b/apptentive/src/test/java/com/apptentive/android/sdk/util/UtilTest.java @@ -0,0 +1,19 @@ +package com.apptentive.android.sdk.util; + +import org.junit.Test; + +import java.io.File; + +import static org.junit.Assert.*; + +public class UtilTest { + + @Test + public void getEncryptedFilename() { + File file = new File("/data/user/0/test.bin"); + File encryptedFile = Util.getEncryptedFilename(file); + assertEquals(new File("/data/user/0/test.bin.encrypted"), encryptedFile); + File decryptedFile = Util.getUnencryptedFilename(encryptedFile); + assertEquals(file, decryptedFile); + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 92dca024a..658cd9822 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:3.1.3' + classpath 'com.android.tools.build:gradle:3.1.4' classpath 'com.github.3mph4515:gradle-hockeyapp-plugin:3.7.6' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/samples/apptentive-example/build.gradle b/samples/apptentive-example/build.gradle index be4991faf..684728522 100644 --- a/samples/apptentive-example/build.gradle +++ b/samples/apptentive-example/build.gradle @@ -21,12 +21,12 @@ dependencies { } android { - compileSdkVersion 27 - buildToolsVersion '27.0.3' + compileSdkVersion 28 + buildToolsVersion '28.0.2' defaultConfig { minSdkVersion 14 - targetSdkVersion 25 + targetSdkVersion 28 versionCode 1 versionName "1.0" multiDexEnabled true diff --git a/tests/test-app/build.gradle b/tests/test-app/build.gradle index 1badadd85..3f90f4fe8 100644 --- a/tests/test-app/build.gradle +++ b/tests/test-app/build.gradle @@ -7,7 +7,7 @@ android { defaultConfig { minSdkVersion 14 - targetSdkVersion 25 + targetSdkVersion 27 versionCode 4 versionName "2.0" }