diff --git a/CHANGELOG.md b/CHANGELOG.md index 5259ee9b7..47b205c63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,164 @@ This project adheres to [Semantic Versioning](http://semver.org/). #### Changed - nothing yet +## [3.4.17](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.17) +#### Added +- when JWT is invalid, `IterableAuthManager` is updated to fetch and store a new JWT token locally +- `IterableRequestTask` now has a retry mechanism that fetches a new JWT token and retries the request if JWT is invalid +- retries are capped at a max of 5 + +## [3.4.16](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.16) #### Fixed -- nothing yet +- SDK now handles `null` scenarios preventing crashes when `IterableEncryptedSharedPreference` creation fails. +- Updated crypto library to version [1.1.0-alpha06](https://developer.android.com/jetpack/androidx/releases/security#1.1.0-alpha06). [1.1.0-alpha05](https://developer.android.com/jetpack/androidx/releases/security#1.1.0-alpha05) solves a race condition during creation process. -## [3.4.9](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.9) +## [3.4.15](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.15) +#### Added + +This release allows you to use projects hosted on Iterable's EU data center. If your project is hosted on Iterable's [European data center (EUDC)](https://support.iterable.com/hc/articles/17572750887444), configure the SDK to use Iterable's EU-based API endpoints: + +_Java_ + +```java +IterableConfig config = new IterableConfig.Builder() + // ... other configuration options ... + .setDataRegion(IterableDataRegion.EU) + .build(); +IterableApi.initialize(context, "", config); +``` + +_Kotlin_ + +```kotlin +val configBuilder = IterableConfig.Builder() + // ... other configuration options ... + .setDataRegion(IterableDataRegion.EU) + .build(); +IterableApi.initialize(context, "", config); +``` + +#### Fixed +- Addressed React Native SDK push notification deep linking issues where the app would restart instead of resuming the last activity upon being backgrounded. +- Resolves an additional push notification problem wherein the customActionHandler and urlHandler were not being invoked in specific scenarios, as documented in issue #470. (Credit to @tnortman-jabra for the report and the fix) + +## [3.4.14](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.14) +#### Added +- `IterableInAppManager.setRead` now accepts `IterableHelper.FailureHandler failureHandler` + +#### Fixed +- Fixes an issue where `IterableInAppManager.removeMessage` caused build failure in React Native SDK pointing to legacy method calls. +- Fixes an issue where custom action handlers were not invoked when tapping on push notification when the app is in background. + +## [3.4.13](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.13) +#### Added +- `IterableInAppManager.setRead` now accepts `IterableHelper.SuccessHandler successHandler`. +- `IterableApi.inAppConsume` now accepts `IterableHelper.SuccessHandler successHandler` and `IterableHelper.FailureHandler failureHandler`. + +## [3.4.12](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.12) +#### Added +- `setEmail` and `setUserId` now accepts `IterableHelper.SuccessHandler successHandler` and `IterableHelper.FailureHandler failureHandler`. + +#### Changed +- OTT devices (FireTV) will now register as `OTT` device instead of `Android` under user's devices. + +## [3.4.11](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.11) + +#### Added + +- Custom push notification sounds! To play a custom sound for a push notification, add a sound file to your app's `res/raw` folder and specify that same filename when setting up a template in Iterable. + + Some important notes about custom sounds and notification channels: + + - Android API level 26 introduced [notification channels](https://developer.android.com/develop/ui/views/notifications/channels). Every notification must be assigned to a channel. + - Each custom sound you add to an Iterable template creates a new Android notification channel. The notification channel's name matches the filename of the sound (without its extension). + - To ensure sensible notification channel names for end users, give friendly names to your sound files. For example, a custom sound file with name `Paid.mp3` creates a notification channel called `Paid`. The end user can see this notification channel name in their device's notification channel settings. + - Be sure to place the corresponding sound file in your app's `res/raw` directory. + +- To help you access a user's `email` address, `userId`, and `authToken`, the SDK now provides convenience methods: `getEmail()`, `getUserId()`, and `getAuthToken()`. +#### Changed + +- Updated the [Security library](https://developer.android.com/topic/security/data) and improved `EncryptedSharedPreferences` handling. + + To work around a [known Android issue](https://issuetracker.google.com/issues/164901843) that can cause crashes when creating `EncryptedSharedPreferences`, we've upgraded `androidx.security.crypto` from version `1.0.0` to `1.1.0-alpha04`. When `EncryptedSharedPreferences` cannot be created, the SDK now uses `SharedPreferences` (unencrypted). + + If your app requires encryption, you can prevent this fallback to `SharedPreferences` by setting the `encryptionEnforced` configuration flag to `true`. However, if you enable this flag and `EncryptedSharedPreferences` cannot be created, an exception will be thrown. + +- Improved JWT token management. This change addresses an issue where `null` values could prevent the refresh of a JWT token. + +#### Fixed + +- Fixed an issue which could prevent in-app messages from respecting the **Position** value selected when setting up the template (top / center / bottom / full). + +- Fixed crashes that sometimes happened during in-app message animations. + +## [3.4.10](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.10) +This release includes support for encrypting some data at rest, and an option to +store in-app messages in memory. + +#### Encrypted data + +In Android apps with `minSdkVersion` 23 or higher ([Android 6.0](https://developer.android.com/studio/releases/platforms#6.0)) +Iterable's Android SDK now encrypts the following fields when storing them at +rest: + +- `email` — The user's email address. +- `userId` — The user's ID. +- `authToken` — The JWT used to authenticate the user with Iterable's API. + +(Note that Iterable's Android SDK does not store the last push payload at +rest—before or after this update.) + +For more information about this encryption in Android, examine the source code +for Iterable's Android SDK: [`IterableKeychain`](https://github.com/Iterable/iterable-android-sdk/blob/master/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt). + +#### Storing in-app messages in memory + +This release also allows you to have your Android apps (regardless of `minSdkVersion`) +store in-app messages in memory, rather than in an unencrypted local file. +However, an unencrypted local file is still the default option. + +To store in-app messages in memory, set the `setUseInMemoryStorageForInApps(true)` +SDK configuration option (defaults to `false`): + +_Java_ + +```java +IterableConfig.Builder configBuilder = new IterableConfig.Builder() + // ... other configuration options ... + .setUseInMemoryStorageForInApps(true); +IterableApi.initialize(context, "", config); +``` + +_Kotlin_ + +```kotlin +val configBuilder = IterableConfig.Builder() + // ... other configuration options ... + .setUseInMemoryStorageForInApps(true); +IterableApi.initialize(context, "", configBuilder.build()); +``` + +When users upgrade to a version of your Android app that uses this version of +the SDK (or higher), and you've set this configuration option to `true`, the +local file used for in-app message storage (if it already exists) is deleted +However, no data is lost. + +#### Android upgrade instructions + +If your app targets API level 23 or higher, this is a standard SDK upgrade, with +no special instructions. + +If your app targets an API level less than 23, you'll need to make the following +changes to your project (which allow your app to build, even though it won't +encrypt data): + +1. In `AndroidManifest.xml`, add `` +2. In your app's `app/build.gradle`: + - Add `multiDexEnabled true` to the `default` object, under `android`. + - Add `implementation androidx.multidex:multidex:2.0.1` to the `dependencies`. + +## [3.4.9](https://github.com/Iterable/iterable-android-sdk/releases/tag/3.4.9) #### Added - Added new methods for `setEmail`, `setUserId` and `updateEmail` which accepts `authToken`, providing more ways to pass `authToken` to SDK - Added two interface methods - `onTokenRegistrationSuccessful` and `onTokenRegistrationFailed`. Override these methods to see if authToken was successfully received by the SDK. diff --git a/app/build.gradle b/app/build.gradle index 30368e233..5ee160ad8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,21 +20,25 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled true } + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } + debug { testCoverageEnabled true } } testOptions.unitTests.includeAndroidResources = true + compileOptions { sourceCompatibility = 1.8 targetCompatibility = 1.8 } + kotlinOptions { jvmTarget = "1.8" } @@ -73,9 +77,11 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.2.0' androidTestImplementation 'br.com.concretesolutions:kappuccino:1.2.1' } + tasks.withType(Test) { jacoco.includeNoLocationClasses = true } + task jacocoDebugTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) { group = "reporting" description = "Generate unified Jacoco code coverage report" @@ -103,12 +109,14 @@ task jacocoDebugTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) '**/*$ModuleAdapter.class', '**/*$ViewInjector*.class', ] + def debugTree = fileTree(dir: "${buildDir}/intermediates/javac/debug/classes", excludes: fileFilter) //we use "debug" build type for test coverage (can be other) def sdkTree = fileTree(dir: "${buildDir}/../../iterableapi/build/intermediates/javac/debug/classes", excludes: fileFilter) def sdkUiTree = fileTree(dir: "${buildDir}/../../iterableapi-ui/build/intermediates/javac/debug/classes", excludes: fileFilter) def mainSrc = "${project.projectDir}/src/main/java" def sdkSrc = "${project.projectDir}/../iterableapi/src/main/java" def sdkUiSrc = "${project.projectDir}/../iterableapi-ui/src/main/java" + sourceDirectories.from = files([mainSrc]) classDirectories.from = files([debugTree]) additionalSourceDirs.from = files([sdkSrc, sdkUiSrc]) @@ -121,11 +129,13 @@ task jacocoDebugTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest']) task jacocoDebugAndroidTestReport(type: JacocoReport, dependsOn: ['connectedCheck']) { group = "reporting" description = "Generate Jacoco code coverage report for instumentation tests" + reports { xml.enabled = true html.enabled = true csv.enabled = false } + def fileFilter = [ '**/*Test*.*', '**/AutoValue_*.*', @@ -145,12 +155,14 @@ task jacocoDebugAndroidTestReport(type: JacocoReport, dependsOn: ['connectedChec '**/*$ModuleAdapter.class', '**/*$ViewInjector*.class', ] + def debugTree = fileTree(dir: "${buildDir}/intermediates/javac/debug/classes", excludes: fileFilter) //we use "debug" build type for test coverage (can be other) def sdkTree = fileTree(dir: "${buildDir}/../../iterableapi/build/intermediates/javac/debug/classes", excludes: fileFilter) def sdkUiTree = fileTree(dir: "${buildDir}/../../iterableapi-ui/build/intermediates/javac/debug/classes", excludes: fileFilter) def mainSrc = "${project.projectDir}/src/main/java" def sdkSrc = "${project.projectDir}/../iterableapi/src/main/java" def sdkUiSrc = "${project.projectDir}/../iterableapi-ui/src/main/java" + sourceDirectories.from = files([mainSrc]) classDirectories.from = files([debugTree]) additionalSourceDirs.from = files([sdkSrc, sdkUiSrc]) diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml index 6f14a20ff..2be8ab74b 100644 --- a/app/src/androidTest/AndroidManifest.xml +++ b/app/src/androidTest/AndroidManifest.xml @@ -1,5 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e25741b9b..28a40e935 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ - + - - - + \ No newline at end of file diff --git a/iterableapi-ui/build.gradle b/iterableapi-ui/build.gradle index ea21aab6e..b04bef695 100644 --- a/iterableapi-ui/build.gradle +++ b/iterableapi-ui/build.gradle @@ -48,7 +48,7 @@ ext { siteUrl = 'https://github.com/Iterable/iterable-android-sdk' gitUrl = 'https://github.com/Iterable/iterable-android-sdk.git' - libraryVersion = '3.4.9' + libraryVersion = '3.4.17' developerId = 'davidtruong' developerName = 'David Truong' @@ -60,7 +60,8 @@ ext { } apply from: 'https://raw.githubusercontent.com/nuuneoi/JCenter/master/installv1.gradle' -if(hasProperty("mavenPublishEnabled")) { + +if (hasProperty("mavenPublishEnabled")) { apply from: '../maven-push.gradle' } diff --git a/iterableapi-ui/src/main/AndroidManifest.xml b/iterableapi-ui/src/main/AndroidManifest.xml index f365cfc97..e3d6a822e 100644 --- a/iterableapi-ui/src/main/AndroidManifest.xml +++ b/iterableapi-ui/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + - - + + + android:supportsRtl="true" /> \ No newline at end of file diff --git a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java index d94d1ba5c..22b95e866 100644 --- a/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java +++ b/iterableapi/src/androidTest/java/com/iterable/iterableapi/IterableApiResponseTest.java @@ -7,6 +7,7 @@ import androidx.test.filters.MediumTest; import org.hamcrest.CoreMatchers; +import org.json.JSONException; import org.json.JSONObject; import org.junit.After; import org.junit.Before; @@ -25,6 +26,7 @@ import static com.iterable.iterableapi.IterableTestUtils.createIterableApi; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; import static org.junit.Assert.assertThat; @@ -198,6 +200,72 @@ public void onFailure(@NonNull String reason, @Nullable JSONObject data) { assertTrue("onFailure is called", signal.await(1, TimeUnit.SECONDS)); } + @Test + public void testRetryOnInvalidJwtPayload() throws Exception { + final CountDownLatch signal = new CountDownLatch(3); + stubAnyRequestReturningStatusCode(401, "{\"msg\":\"JWT Authorization header error\",\"code\":\"InvalidJwtPayload\"}"); + + IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, new IterableHelper.FailureHandler() { + @Override + public void onFailure(@NonNull String reason, @Nullable JSONObject data) { + try { + if (data != null && "InvalidJwtPayload".equals(data.optString("code"))) { + final JSONObject responseData = new JSONObject("{\n" + + " \"key\":\"Success\",\n" + + " \"message\":\"Event tracked successfully.\"\n" + + "}"); + stubAnyRequestReturningStatusCode(200, responseData); + + new IterableRequestTask().execute(new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, new IterableHelper.SuccessHandler() { + @Override + public void onSuccess(@NonNull JSONObject successData) { + try { + assertEquals(responseData.toString(), successData.toString()); + } catch (AssertionError e) { + e.printStackTrace(); + } finally { + signal.countDown(); + } + } + }, null)); + server.takeRequest(2, TimeUnit.SECONDS); + } + } catch (JSONException e) { + e.printStackTrace(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + signal.countDown(); + } + } + }); + + new IterableRequestTask().execute(request); + server.takeRequest(1, TimeUnit.SECONDS); + + // Await for the background tasks to complete + signal.await(5, TimeUnit.SECONDS); + } + + @Test + public void testMaxRetriesOnMultipleInvalidJwtPayloads() throws Exception { + for (int i = 0; i < 5; i++) { + stubAnyRequestReturningStatusCode(401, "{\"msg\":\"JWT Authorization header error\",\"code\":\"InvalidJwtPayload\"}"); + } + + IterableApiRequest request = new IterableApiRequest("fake_key", "", new JSONObject(), IterableApiRequest.POST, null, null, null); + IterableRequestTask task = new IterableRequestTask(); + task.execute(request); + + RecordedRequest request1 = server.takeRequest(1, TimeUnit.SECONDS); + RecordedRequest request2 = server.takeRequest(5, TimeUnit.SECONDS); + RecordedRequest request3 = server.takeRequest(5, TimeUnit.SECONDS); + RecordedRequest request4 = server.takeRequest(5, TimeUnit.SECONDS); + RecordedRequest request5 = server.takeRequest(5, TimeUnit.SECONDS); + RecordedRequest request6 = server.takeRequest(5, TimeUnit.SECONDS); + assertNull("Request should be null since retries hit the max of 5", request6); + } + @Test public void testResponseCode500() throws Exception { for (int i = 0; i < 5; i++) { diff --git a/iterableapi/src/main/AndroidManifest.xml b/iterableapi/src/main/AndroidManifest.xml index 84c623247..581b85f80 100644 --- a/iterableapi/src/main/AndroidManifest.xml +++ b/iterableapi/src/main/AndroidManifest.xml @@ -1,9 +1,10 @@ - + + + diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 1f30a8604..fd9eec6e0 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -49,6 +49,7 @@ public class IterableApi { private String inboxSessionId; private IterableAuthManager authManager; private HashMap deviceAttributes = new HashMap<>(); + private IterableKeychain keychain; void fetchRemoteConfiguration() { apiClient.getRemoteConfiguration(new IterableHelper.IterableActionHandler() { @@ -135,6 +136,19 @@ IterableAuthManager getAuthManager() { return authManager; } + @Nullable + IterableKeychain getKeychain() { + if (keychain == null) { + try { + keychain = new IterableKeychain(getMainActivityContext(), config.encryptionEnforced); + } catch (Exception e) { + IterableLogger.e(TAG, "Failed to create IterableKeychain", e); + } + } + + return keychain; + } + static void loadLastSavedConfiguration(Context context) { SharedPreferences sharedPref = context.getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE); boolean offlineMode = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, false); @@ -376,33 +390,33 @@ private String getDeviceId() { } private void storeAuthData() { - try { - SharedPreferences.Editor editor = getPreferences().edit(); - editor.putString(IterableConstants.SHARED_PREFS_EMAIL_KEY, _email); - editor.putString(IterableConstants.SHARED_PREFS_USERID_KEY, _userId); - editor.putString(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY, _authToken); - editor.commit(); - } catch (Exception e) { - IterableLogger.e(TAG, "Error while persisting email/userId", e); + IterableKeychain iterableKeychain = getKeychain(); + if (iterableKeychain != null) { + iterableKeychain.saveEmail(_email); + iterableKeychain.saveUserId(_userId); + iterableKeychain.saveAuthToken(_authToken); + } else { + IterableLogger.e(TAG, "Shared preference creation failed. "); } } private void retrieveEmailAndUserId() { - try { - SharedPreferences prefs = getPreferences(); - _email = prefs.getString(IterableConstants.SHARED_PREFS_EMAIL_KEY, null); - _userId = prefs.getString(IterableConstants.SHARED_PREFS_USERID_KEY, null); - _authToken = prefs.getString(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY, null); - if (config.authHandler != null) { - if (_authToken != null) { - getAuthManager().queueExpirationRefresh(_authToken); - } else { - IterableLogger.d(TAG, "Auth token found as null. Scheduling token refresh in 10 seconds..."); - getAuthManager().scheduleAuthTokenRefresh(10000); - } + IterableKeychain iterableKeychain = getKeychain(); + if (iterableKeychain != null) { + _email = iterableKeychain.getEmail(); + _userId = iterableKeychain.getUserId(); + _authToken = iterableKeychain.getAuthToken(); + } else { + IterableLogger.e(TAG, "retrieveEmailAndUserId: Shared preference creation failed. Could not retrieve email/userId"); + } + + if (config.authHandler != null) { + if (_authToken != null) { + getAuthManager().queueExpirationRefresh(_authToken); + } else { + IterableLogger.d(TAG, "Auth token found as null. Scheduling token refresh in 10 seconds..."); + getAuthManager().scheduleAuthTokenRefresh(10000); } - } catch (Exception e) { - IterableLogger.e(TAG, "Error while retrieving email/userId/authToken", e); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 6ad569c6c..4e7c23de9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -4,14 +4,14 @@ import androidx.annotation.VisibleForTesting; -import com.iterable.iterableapi.util.Future; - +import org.json.JSONException; import org.json.JSONObject; import java.io.UnsupportedEncodingException; import java.util.Timer; import java.util.TimerTask; -import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; public class IterableAuthManager { private static final String TAG = "IterableAuth"; @@ -20,13 +20,15 @@ public class IterableAuthManager { private final IterableApi api; private final IterableAuthHandler authHandler; private final long expiringAuthTokenRefreshPeriod; - + private final long scheduledRefreshPeriod = 10000; @VisibleForTesting Timer timer; private boolean hasFailedPriorAuth; private boolean pendingAuth; private boolean requiresAuthRefresh; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, long expiringAuthTokenRefreshPeriod) { this.api = api; this.authHandler = authHandler; @@ -34,41 +36,37 @@ public class IterableAuthManager { } public synchronized void requestNewAuthToken(boolean hasFailedPriorAuth) { + requestNewAuthToken(hasFailedPriorAuth, null); + } + + private void handleSuccessForAuthToken(String authToken, IterableHelper.SuccessHandler successCallback) { + try { + JSONObject object = new JSONObject(); + object.put("newAuthToken", authToken); + successCallback.onSuccess(object); + } catch (JSONException e) { + e.printStackTrace(); + } + } + + public synchronized void requestNewAuthToken( + boolean hasFailedPriorAuth, + final IterableHelper.SuccessHandler successCallback) { if (authHandler != null) { if (!pendingAuth) { if (!(this.hasFailedPriorAuth && hasFailedPriorAuth)) { this.hasFailedPriorAuth = hasFailedPriorAuth; pendingAuth = true; - Future.runAsync(new Callable() { - @Override - public String call() throws Exception { - return authHandler.onAuthTokenRequested(); - } - }).onSuccess(new Future.SuccessCallback() { + + executor.submit(new Runnable() { @Override - public void onSuccess(String authToken) { - if (authToken != null) { - queueExpirationRefresh(authToken); - } else { - IterableLogger.w(TAG, "Auth token received as null. Calling the handler in 10 seconds"); - //TODO: Make this time configurable and in sync with SDK initialization flow for auth null scenario - scheduleAuthTokenRefresh(10000); - authHandler.onTokenRegistrationFailed(new Throwable("Auth token null")); - return; + public void run() { + try { + final String authToken = authHandler.onAuthTokenRequested(); + handleAuthTokenSuccess(authToken, successCallback); + } catch (final Exception e) { + handleAuthTokenFailure(e); } - IterableApi.getInstance().setAuthToken(authToken); - pendingAuth = false; - reSyncAuth(); - authHandler.onTokenRegistrationSuccessful(authToken); - } - }) - .onFailure(new Future.FailureCallback() { - @Override - public void onFailure(Throwable throwable) { - IterableLogger.e(TAG, "Error while requesting Auth Token", throwable); - authHandler.onTokenRegistrationFailed(throwable); - pendingAuth = false; - reSyncAuth(); } }); } @@ -82,6 +80,32 @@ public void onFailure(Throwable throwable) { } } + private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHandler successCallback) { + if (authToken != null) { + if (successCallback != null) { + handleSuccessForAuthToken(authToken, successCallback); + } + queueExpirationRefresh(authToken); + } else { + IterableLogger.w(TAG, "Auth token received as null. Calling the handler in 10 seconds"); + //TODO: Make this time configurable and in sync with SDK initialization flow for auth null scenario + scheduleAuthTokenRefresh(scheduledRefreshPeriod); + authHandler.onTokenRegistrationFailed(new Throwable("Auth token null")); + return; + } + IterableApi.getInstance().setAuthToken(authToken); + pendingAuth = false; + reSyncAuth(); + authHandler.onTokenRegistrationSuccessful(authToken); + } + + private void handleAuthTokenFailure(Throwable throwable) { + IterableLogger.e(TAG, "Error while requesting Auth Token", throwable); + authHandler.onTokenRegistrationFailed(throwable); + pendingAuth = false; + reSyncAuth(); + } + public void queueExpirationRefresh(String encodedJWT) { clearRefreshTimer(); try { @@ -96,7 +120,7 @@ public void queueExpirationRefresh(String encodedJWT) { IterableLogger.e(TAG, "Error while parsing JWT for the expiration", e); authHandler.onTokenRegistrationFailed(new Throwable("Auth token decode failure. Scheduling auth token refresh in 10 seconds...")); //TODO: Sync with configured time duration once feature is available. - scheduleAuthTokenRefresh(10000); + scheduleAuthTokenRefresh(scheduledRefreshPeriod); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index a0945962c..73a8e8397 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -82,6 +82,8 @@ public class IterableConfig { */ final boolean useInMemoryStorageForInApps; + final boolean encryptionEnforced; + private IterableConfig(Builder builder) { pushIntegrationName = builder.pushIntegrationName; urlHandler = builder.urlHandler; @@ -96,6 +98,7 @@ private IterableConfig(Builder builder) { allowedProtocols = builder.allowedProtocols; dataRegion = builder.dataRegion; useInMemoryStorageForInApps = builder.useInMemoryStorageForInApps; + encryptionEnforced = builder.encryptionEnforced; } public static class Builder { @@ -112,6 +115,7 @@ public static class Builder { private String[] allowedProtocols = new String[0]; private IterableDataRegion dataRegion = IterableDataRegion.US; private boolean useInMemoryStorageForInApps = false; + private boolean encryptionEnforced = false; public Builder() {} @@ -233,6 +237,17 @@ public Builder setAllowedProtocols(@NonNull String[] allowedProtocols) { return this; } + /** + * Set whether the SDK should enforce encryption. If set to `true`, the SDK will not use fallback mechanism + * of storing data in un-encrypted shared preferences if encrypted database is not available. Set this to `true` + * if PII confidentiality is a concern for your app. + * @param encryptionEnforced `true` will have the SDK enforce encryption. + */ + public Builder setEncryptionEnforced(boolean encryptionEnforced) { + this.encryptionEnforced = encryptionEnforced; + return this; + } + /** * Set the data region used by the SDK * @param dataRegion enum value that determines which endpoint to use, defaults to IterableDataRegion.US diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt new file mode 100644 index 000000000..df9aae23a --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt @@ -0,0 +1,155 @@ +package com.iterable.iterableapi + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +class IterableKeychain { + + private val TAG = "IterableKeychain" + private var sharedPrefs: SharedPreferences + + private val encryptedSharedPrefsFileName = "iterable-encrypted-shared-preferences" + + private val emailKey = "iterable-email" + private val userIdKey = "iterable-user-id" + private val authTokenKey = "iterable-auth-token" + + private var encryptionEnabled = false + + constructor(context: Context, encryptionEnforced: Boolean) { + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + encryptionEnabled = false + sharedPrefs = context.getSharedPreferences( + IterableConstants.SHARED_PREFS_FILE, + Context.MODE_PRIVATE + ) + IterableLogger.v(TAG, "SharedPreferences being used") + } else { + // See if EncryptedSharedPreferences can be created successfully + try { + val masterKeyAlias = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + sharedPrefs = EncryptedSharedPreferences.create( + context, + encryptedSharedPrefsFileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + encryptionEnabled = true + } catch (e: Throwable) { + if (e is Error) { + IterableLogger.e( + TAG, + "EncryptionSharedPreference creation failed with Error. Attempting to continue" + ) + } + + if (encryptionEnforced) { + //TODO: In-memory or similar solution needs to be implemented in the future. + IterableLogger.w( + TAG, + "Encryption is enforced. PII will not be persisted due to EncryptionSharedPreference failure. Email/UserId and Auth token will have to be passed for every app session.", + e + ) + throw e.fillInStackTrace() + } else { + sharedPrefs = context.getSharedPreferences( + IterableConstants.SHARED_PREFS_FILE, + Context.MODE_PRIVATE + ) + IterableLogger.w( + TAG, + "Using SharedPreference as EncryptionSharedPreference creation failed." + ) + encryptionEnabled = false + } + } + + //Try to migrate data from SharedPreferences to EncryptedSharedPreferences + if (encryptionEnabled) { + migrateAuthDataFromSharedPrefsToKeychain(context) + } + } + } + + fun getEmail(): String? { + return sharedPrefs.getString(emailKey, null) + } + + fun saveEmail(email: String?) { + sharedPrefs.edit() + .putString(emailKey, email) + .apply() + } + + fun getUserId(): String? { + return sharedPrefs.getString(userIdKey, null) + } + + fun saveUserId(userId: String?) { + sharedPrefs.edit() + .putString(userIdKey, userId) + .apply() + } + + fun getAuthToken(): String? { + return sharedPrefs.getString(authTokenKey, null) + } + + fun saveAuthToken(authToken: String?) { + sharedPrefs.edit() + .putString(authTokenKey, authToken) + .apply() + } + + @RequiresApi(api = Build.VERSION_CODES.M) + private fun migrateAuthDataFromSharedPrefsToKeychain(context: Context) { + val oldPrefs: SharedPreferences = context.getSharedPreferences( + IterableConstants.SHARED_PREFS_FILE, + Context.MODE_PRIVATE + ) + val sharedPrefsEmail = oldPrefs.getString(IterableConstants.SHARED_PREFS_EMAIL_KEY, null) + val sharedPrefsUserId = oldPrefs.getString(IterableConstants.SHARED_PREFS_USERID_KEY, null) + val sharedPrefsAuthToken = + oldPrefs.getString(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY, null) + val editor: SharedPreferences.Editor = oldPrefs.edit() + if (getEmail() == null && sharedPrefsEmail != null) { + saveEmail(sharedPrefsEmail) + editor.remove(IterableConstants.SHARED_PREFS_EMAIL_KEY) + IterableLogger.v( + TAG, + "UPDATED: migrated email from SharedPreferences to IterableKeychain" + ) + } else if (sharedPrefsEmail != null) { + editor.remove(IterableConstants.SHARED_PREFS_EMAIL_KEY) + } + if (getUserId() == null && sharedPrefsUserId != null) { + saveUserId(sharedPrefsUserId) + editor.remove(IterableConstants.SHARED_PREFS_USERID_KEY) + IterableLogger.v( + TAG, + "UPDATED: migrated userId from SharedPreferences to IterableKeychain" + ) + } else if (sharedPrefsUserId != null) { + editor.remove(IterableConstants.SHARED_PREFS_USERID_KEY) + } + if (getAuthToken() == null && sharedPrefsAuthToken != null) { + saveAuthToken(sharedPrefsAuthToken) + editor.remove(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY) + IterableLogger.v( + TAG, + "UPDATED: migrated authToken from SharedPreferences to IterableKeychain" + ) + } else if (sharedPrefsAuthToken != null) { + editor.remove(IterableConstants.SHARED_PREFS_AUTH_TOKEN_KEY) + } + editor.apply() + } +} \ No newline at end of file diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index 9499db682..dc408b6f4 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -33,7 +33,6 @@ static boolean executeAction(Context context, PendingAction action) { return IterableActionRunner.executeAction(context, action.iterableAction, IterableActionSource.PUSH); } - static void handlePushAction(Context context, Intent intent) { if (intent.getExtras() == null) { IterableLogger.e(TAG, "handlePushAction: extras == null, can't handle push action"); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index d5fe00046..f011ba273 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -3,6 +3,8 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Handler; +import android.os.Looper; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; @@ -38,11 +40,13 @@ class IterableRequestTask extends AsyncTask= 500; - - if (retryRequest && retryCount <= MAX_RETRY_COUNT) { - final IterableRequestTask requestTask = new IterableRequestTask(); - requestTask.setRetryCount(retryCount + 1); - - long delay = 0; - if (retryCount > 2) { - delay = RETRY_DELAY_MS * retryCount; - } - Handler handler = new Handler(); - handler.postDelayed(new Runnable() { - @Override - public void run() { - requestTask.execute(iterableApiRequest); - } - }, delay); + if (shouldRetry(response)) { + retryRequestWithDelay(); return; } else if (response.success) { - IterableApi.getInstance().getAuthManager().resetFailedAuth(); - if (iterableApiRequest.successCallback != null) { - iterableApiRequest.successCallback.onSuccess(response.responseJson); - } + handleSuccessResponse(response); } else { - if (matchesErrorCode(response.responseJson, ERROR_CODE_INVALID_JWT_PAYLOAD)) { - IterableApi.getInstance().getAuthManager().requestNewAuthToken(true); - } - if (iterableApiRequest.failureCallback != null) { - iterableApiRequest.failureCallback.onFailure(response.errorMessage, response.responseJson); - } + handleErrorResponse(response); } + if (iterableApiRequest.legacyCallback != null) { iterableApiRequest.legacyCallback.execute(response.responseBody); } super.onPostExecute(response); } + private boolean shouldRetry(IterableApiResponse response) { + return !response.success && response.responseCode >= 500 && retryCount <= MAX_RETRY_COUNT; + } + + private void retryRequestWithDelay() { + final IterableRequestTask requestTask = new IterableRequestTask(); + requestTask.setRetryCount(retryCount + 1); + + long delay = (retryCount > 2) ? RETRY_DELAY_MS * retryCount : 0; + + handler.postDelayed(new Runnable() { + @Override + public void run() { + requestTask.execute(iterableApiRequest); + } + }, delay); + } + + private void handleSuccessResponse(IterableApiResponse response) { + IterableApi.getInstance().getAuthManager().resetFailedAuth(); + if (iterableApiRequest.successCallback != null) { + iterableApiRequest.successCallback.onSuccess(response.responseJson); + } + } + + private void handleErrorResponse(IterableApiResponse response) { + if (matchesErrorCode(response.responseJson, ERROR_CODE_INVALID_JWT_PAYLOAD) && shouldRetryWhileJwtInvalid) { + requestNewAuthTokenAndRetry(response); + } + + if (iterableApiRequest.failureCallback != null) { + iterableApiRequest.failureCallback.onFailure(response.errorMessage, response.responseJson); + } + } + + private void requestNewAuthTokenAndRetry(IterableApiResponse response) { + IterableApi.getInstance().getAuthManager().requestNewAuthToken(false, data -> { + try { + String newAuthToken = data.getString("newAuthToken"); + retryRequestWithNewAuthToken(newAuthToken); + } catch (JSONException e) { + e.printStackTrace(); + } + }); + } + protected void setRetryCount(int count) { retryCount = count; } - } /** diff --git a/iterableapi/src/test/AndroidManifest.xml b/iterableapi/src/test/AndroidManifest.xml index e0d1b4219..ef9128d8d 100644 --- a/iterableapi/src/test/AndroidManifest.xml +++ b/iterableapi/src/test/AndroidManifest.xml @@ -1,6 +1,6 @@ - + + diff --git a/sample-apps/inbox-customization/app/build.gradle b/sample-apps/inbox-customization/app/build.gradle index cf698faca..75859e5f1 100644 --- a/sample-apps/inbox-customization/app/build.gradle +++ b/sample-apps/inbox-customization/app/build.gradle @@ -33,8 +33,8 @@ dependencies { implementation 'androidx.navigation:navigation-ui-ktx:2.1.0' implementation 'com.google.android.material:material:1.1.0' - implementation 'com.iterable:iterableapi:3.4.9' - implementation 'com.iterable:iterableapi-ui:3.4.9' + implementation 'com.iterable:iterableapi:3.4.17' + implementation 'com.iterable:iterableapi-ui:3.4.17' implementation 'com.squareup.okhttp3:mockwebserver:4.2.2' testImplementation 'junit:junit:4.12'