diff --git a/code/assurance-testapp/src/main/AndroidManifest.xml b/code/assurance-testapp/src/main/AndroidManifest.xml index 095bd72..b3a1c30 100644 --- a/code/assurance-testapp/src/main/AndroidManifest.xml +++ b/code/assurance-testapp/src/main/AndroidManifest.xml @@ -25,12 +25,12 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme"> + android:theme="@style/AppThemeLight"> + android:theme="@style/AppThemeLight"> diff --git a/code/assurance-testapp/src/main/java/com/adobe/marketing/mobile/assurance/testapp/ui/views/AssuranceView.kt b/code/assurance-testapp/src/main/java/com/adobe/marketing/mobile/assurance/testapp/ui/views/AssuranceView.kt index f8c9a59..68a505a 100644 --- a/code/assurance-testapp/src/main/java/com/adobe/marketing/mobile/assurance/testapp/ui/views/AssuranceView.kt +++ b/code/assurance-testapp/src/main/java/com/adobe/marketing/mobile/assurance/testapp/ui/views/AssuranceView.kt @@ -119,6 +119,13 @@ private fun AssuranceConnectionInput() { ) { Text(text = stringResource(id = R.string.assurance_connection_button_name)) } + + Button( + onClick = { Assurance.startSession() }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.assurance_quick_connect_button_name)) + } } } diff --git a/code/assurance-testapp/src/main/res/values/strings.xml b/code/assurance-testapp/src/main/res/values/strings.xml index 8f08427..91c1a36 100644 --- a/code/assurance-testapp/src/main/res/values/strings.xml +++ b/code/assurance-testapp/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ Assurance v%1$s Enter Assurance Connection URL Start Session + Quick Connect Event Chunking Send Small Payload diff --git a/code/assurance-testapp/src/main/res/values/styles.xml b/code/assurance-testapp/src/main/res/values/styles.xml deleted file mode 100644 index fcf9fa5..0000000 --- a/code/assurance-testapp/src/main/res/values/styles.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - diff --git a/code/assurance/build.gradle b/code/assurance/build.gradle index d3e6046..9a8e019 100644 --- a/code/assurance/build.gradle +++ b/code/assurance/build.gradle @@ -15,6 +15,7 @@ plugins { id 'maven-publish' id 'signing' id 'com.diffplug.spotless' + id 'kotlin-android' } // Apply local gradle tasks @@ -45,6 +46,10 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion + // provides a major advantage by preventing the generation + // of individual png icons for each screen size in favor of a single vector + vectorDrawables.useSupportLibrary = true + buildConfigField("String","EXTENSION_VERSION","\"${rootProject.moduleVersion}\"") testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' @@ -79,6 +84,11 @@ android { targetCompatibility rootProject.ext.targetCompatibility } + kotlinOptions { + jvmTarget = rootProject.ext.kotlinJvmTarget + languageVersion = rootProject.ext.kotlinLanguageVersion + apiVersion = rootProject.ext.kotlinApiVersion + } } @@ -142,7 +152,9 @@ publishing { url = 'https://developer.adobe.com/client-sdks' licenses { license { - name = 'Apache License, Version 2.0' + name = 'The Apache License, Version 2.0' + url = 'https://www.apache.org/licenses/LICENSE-2.0.txt' + distribution = 'repo' } } developers { @@ -235,11 +247,13 @@ task platformFunctionalTestJacocoReport(type: JacocoReport, dependsOn: "createPh dependencies { implementation 'androidx.appcompat:appcompat:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinApiVersion" implementation "com.adobe.marketing.mobile:core:${rootProject.mavenCoreVersion}" testImplementation 'junit:junit:4.13' testImplementation 'com.google.code.gson:gson:2.8.5' - testImplementation "org.mockito:mockito-core:4.5.1" + testImplementation 'org.mockito:mockito-core:4.5.1' + testImplementation 'org.mockito.kotlin:mockito-kotlin:3.2.0' testImplementation 'org.mockito:mockito-inline:4.5.1' testImplementation 'net.sf.kxml:kxml2:2.3.0@jar' testImplementation 'org.json:json:20171018' diff --git a/code/assurance/src/main/AndroidManifest.xml b/code/assurance/src/main/AndroidManifest.xml index a9e24df..704331b 100644 --- a/code/assurance/src/main/AndroidManifest.xml +++ b/code/assurance/src/main/AndroidManifest.xml @@ -14,5 +14,6 @@ + diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/Assurance.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/Assurance.java index 0e42daf..6bbfadd 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/Assurance.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/Assurance.java @@ -15,6 +15,7 @@ import androidx.annotation.NonNull; import com.adobe.marketing.mobile.assurance.AssuranceExtension; import com.adobe.marketing.mobile.services.Log; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -23,12 +24,13 @@ public class Assurance { public static final Class EXTENSION = AssuranceExtension.class; public static final String LOG_TAG = "Assurance"; - public static final String EXTENSION_VERSION = "2.0.1"; + public static final String EXTENSION_VERSION = "2.1.0"; public static final String EXTENSION_NAME = "com.adobe.assurance"; public static final String EXTENSION_FRIENDLY_NAME = "Assurance"; private static final String DEEPLINK_SESSION_ID_KEY = "adb_validation_sessionid"; private static final String START_SESSION_URL = "startSessionURL"; + private static final String IS_QUICK_CONNECT = "quickConnect"; // ======================================================================================== // Public APIs @@ -102,4 +104,23 @@ public static void startSession(@NonNull final String url) { .build(); MobileCore.dispatchEvent(startSessionEvent); } + + /** + * Starts an Assurance session via quick flow. Invoking this method on a non-debuggable build, + * or when a session already exists will result in a no-op. + */ + public static void startSession() { + Log.debug(LOG_TAG, LOG_TAG, "QuickConnect api triggered."); + + // Send a quick connect start session event irrespective of the build here. + // Validation will be done when the extension handles this event. + final Event startSessionEvent = + new Event.Builder( + "Assurance Start Session (Quick Connect)", + EventType.ASSURANCE, + EventSource.REQUEST_CONTENT) + .setEventData(Collections.singletonMap(IS_QUICK_CONNECT, true)) + .build(); + MobileCore.dispatchEvent(startSessionEvent); + } } diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceComponentRegistry.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceComponentRegistry.kt new file mode 100644 index 0000000..e51b816 --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceComponentRegistry.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import com.adobe.marketing.mobile.Assurance +import com.adobe.marketing.mobile.assurance.AssuranceSessionOrchestrator.SessionUIOperationHandler +import com.adobe.marketing.mobile.services.Log + +/** + * Provides components necessary for native presentations to interact with the session. + * This should be initialized when the AssuranceExtension is created to ensure that the + * components are available. + */ +internal object AssuranceComponentRegistry { + private const val LOG_SOURCE = "AssuranceComponentRegistry" + + internal var assuranceStateManager: AssuranceStateManager? = null + private set + + internal var sessionUIOperationHandler: SessionUIOperationHandler? = null + private set + + @JvmName("initialize") + @Synchronized + internal fun initialize( + assuranceStateManager: AssuranceStateManager, + uiOperationHandler: SessionUIOperationHandler + ) { + if (this.assuranceStateManager != null || this.sessionUIOperationHandler != null) { + Log.warning(Assurance.LOG_TAG, LOG_SOURCE, "Components already initialized.") + return + } + + this.assuranceStateManager = assuranceStateManager + this.sessionUIOperationHandler = uiOperationHandler + } +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceConstants.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceConstants.java index 3978588..c6c8e63 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceConstants.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceConstants.java @@ -12,8 +12,10 @@ package com.adobe.marketing.mobile.assurance; +import androidx.annotation.Nullable; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.TimeUnit; final class AssuranceConstants { static final String VENDOR_ASSURANCE_MOBILE = "com.adobe.griffon.mobile"; @@ -39,6 +41,7 @@ private GenericEventPayloadKey() {} static final class SDKEventDataKey { static final String START_SESSION_URL = "startSessionURL"; + static final String IS_QUICK_CONNECT = "quickConnect"; static final String EXTENSIONS = "extensions"; static final String STATE_OWNER = "stateowner"; static final String FRIENDLY_NAME = "friendlyName"; @@ -222,6 +225,35 @@ static final class SocketCloseCode { static final int SESSION_DELETED = 4903; private SocketCloseCode() {} + + /** + * Converts a socket close code to an {@code AssuranceConnectionError} if such a mapping + * exists. Not all socket close codes are error codes and not all AssuranceConnectionErrors + * are socket errors. So this utility is needed to bridge socket codes and + * AssuranceConnectionError. + * + * @param closeCode a socket close code for which an AssuranceConnectionError is needed + * @return an {@code AssuranceConnectionError} + */ + @Nullable + static AssuranceConnectionError toAssuranceConnectionError(final int closeCode) { + switch (closeCode) { + case ORG_MISMATCH: + return AssuranceConnectionError.ORG_ID_MISMATCH; + case CLIENT_ERROR: + return AssuranceConnectionError.CLIENT_ERROR; + case CONNECTION_LIMIT: + return AssuranceConnectionError.CONNECTION_LIMIT; + case EVENT_LIMIT: + return AssuranceConnectionError.EVENT_LIMIT; + case SESSION_DELETED: + return AssuranceConnectionError.SESSION_DELETED; + case ABNORMAL: + return AssuranceConnectionError.GENERIC_ERROR; + default: + return null; + } + } } static final class IntentExtraKey { @@ -231,57 +263,95 @@ static final class IntentExtraKey { private IntentExtraKey() {} } + static final class QuickConnect { + static final String BASE_DEVICE_API_URL = "https://device.griffon.adobe.com/device"; + static final String DEVICE_API_PATH_CREATE = "create"; + static final String DEVICE_API_PATH_STATUS = "status"; + static final String KEY_SESSION_ID = "sessionUuid"; + static final String KEY_SESSION_TOKEN = "token"; + static final String KEY_ORG_ID = "orgId"; + static final String KEY_DEVICE_NAME = "deviceName"; + static final String KEY_CLIENT_ID = "clientId"; + static final int CONNECTION_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(5); + static final int READ_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(5); + static final long STATUS_CHECK_DELAY_MS = TimeUnit.SECONDS.toMillis(2); + static final int MAX_RETRY_COUNT = 300; + } + // ======================================================================================== // Enums // ======================================================================================== - enum AssuranceSocketError { + enum AssuranceConnectionError { GENERIC_ERROR( "Connection Error", "The connection may be failing due to a network issue or an incorrect PIN. " - + "Please verify internet connectivity or the PIN and try again."), - NO_ORGID( + + "Please verify internet connectivity or the PIN and try again.", + true), + NO_ORG_ID( "Invalid Configuration", "The Experience Cloud organization identifier is unavailable from the SDK. Ensure" - + " SDK configuration is setup correctly. See documentation for more detail."), - ORGID_MISMATCH( + + " SDK configuration is setup correctly. See documentation for more detail.", + false), + ORG_ID_MISMATCH( "Unauthorized Access", "The Experience Cloud organization identifier does not match with that of the" + " Assurance session. Ensure the right Experience Cloud organization is being" - + " used. See documentation for more detail."), + + " used. See documentation for more detail.", + false), CONNECTION_LIMIT( "Connection Limit Reached", "You have reached the maximum number of connected devices allowed for a session. " - + "Please disconnect another device and try again."), + + "Please disconnect another device and try again.", + false), EVENT_LIMIT( "Event Limit Reached", - "You have reached the maximum number of events that can be sent per minute."), + "You have reached the maximum number of events that can be sent per minute.", + false), CLIENT_ERROR( "Client Disconnected", - "This client has been disconnected due to an unexpected error. Error Code 4400."), + "This client has been disconnected due to an unexpected error. Error Code 4400.", + false), SESSION_DELETED( "Session Deleted", - "The session client connected to has been deleted. Error Code 4903."); + "The session client connected to has been deleted. Error Code 4903.", + false), + + CREATE_DEVICE_REQUEST_MALFORMED( + "Malformed Request", + "The network request for device creation was malformed.", + false), + STATUS_CHECK_REQUEST_MALFORMED( + "Malformed Request", "The network request for status check was malformed.", false), + RETRY_LIMIT_REACHED( + "Retry Limit Reached", + "The maximum allowed retries for fetching the session details were reached.", + true), + CREATE_DEVICE_REQUEST_FAILED("Request Failed", "Failed to register device.", true), + DEVICE_STATUS_REQUEST_FAILED("Request Failed", "Failed to get device status", true), + UNEXPECTED_ERROR("Unexpected Error", "An unexpected error occurred", true); private final String error; - private final String errorDescription; + private final String description; + private final boolean isRetryable; - private AssuranceSocketError(final String error, final String description) { + private AssuranceConnectionError( + final String error, final String description, final boolean isRetryable) { this.error = error; - this.errorDescription = description; + this.description = description; + this.isRetryable = isRetryable; } - String getErrorDescription() { - return errorDescription; + String getDescription() { + return description; } String getError() { return error; } - @Override - public String toString() { - return error + ": " + errorDescription; + boolean isRetryable() { + return isRetryable; } } diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceErrorDisplayActivity.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceErrorDisplayActivity.java index bd2d68a..a00ca84 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceErrorDisplayActivity.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceErrorDisplayActivity.java @@ -12,17 +12,17 @@ package com.adobe.marketing.mobile.assurance; +import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.view.View; import android.view.Window; import android.widget.Button; import android.widget.TextView; -import androidx.appcompat.app.AppCompatActivity; import com.adobe.marketing.mobile.Assurance; import com.adobe.marketing.mobile.services.Log; -public class AssuranceErrorDisplayActivity extends AppCompatActivity { +public class AssuranceErrorDisplayActivity extends Activity { private static final String LOG_TAG = "AssuranceErrorDisplayActivity"; @Override diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceExtension.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceExtension.java index 6b1699c..3b13d73 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceExtension.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceExtension.java @@ -16,6 +16,9 @@ import static com.adobe.marketing.mobile.assurance.AssuranceConstants.PayloadDataKeys.XDM_STATE_DATA; import static com.adobe.marketing.mobile.assurance.AssuranceConstants.SDKEventName.XDM_SHARED_STATE_CHANGE; +import android.app.Activity; +import android.app.Application; +import android.content.Intent; import android.net.Uri; import androidx.annotation.VisibleForTesting; import com.adobe.marketing.mobile.Assurance; @@ -30,6 +33,7 @@ import com.adobe.marketing.mobile.SharedStateStatus; import com.adobe.marketing.mobile.assurance.AssuranceConstants.GenericEventPayloadKey; import com.adobe.marketing.mobile.services.Log; +import com.adobe.marketing.mobile.services.ServiceProvider; import com.adobe.marketing.mobile.util.DataReader; import com.adobe.marketing.mobile.util.DataReaderException; import com.adobe.marketing.mobile.util.StringUtils; @@ -135,9 +139,8 @@ void startSession(final String deeplink) { Log.warning( Assurance.LOG_TAG, LOG_TAG, - "Unable to start Assurance session. Make sure Assurance.registerExtension()is" - + " called before starting the session. For more details refer to" - + " https://aep-sdks.gitbook.io/docs/foundation-extensions/adobe-experience-platform-assurance#register-aepassurance-with-mobile-core"); + "Unable to start Assurance session. Make sure Assurance Extension " + + "is registered before startSession() is called."); return; } @@ -180,9 +183,11 @@ void startSession(final String deeplink) { .START_URL_QUERY_KEY_ENVIRONMENT)); // Create a new session. Note that new session creation via new deeplink will never have a - // PIN and - // will go through the PIN flow. So at this time it is OK to pass a null pin. - assuranceSessionOrchestrator.createSession(sessionId, environment, null); + // PIN and will go through the PIN flow. So at this time it is OK to pass a null pin. + // Additionally, UI state of such a pin session can be controlled via a reference to the + // UI so a status listener/delegate is not needed. + assuranceSessionOrchestrator.createSession( + sessionId, environment, null, null, SessionAuthorizingPresentation.Type.PIN); Log.trace( Assurance.LOG_TAG, LOG_TAG, @@ -190,6 +195,46 @@ void startSession(final String deeplink) { sessionId); } + /** + * Starts an Assurance session via quick connect flow. Invoking this method on a non-debuggable + * build, or when a session already exists will result in a no-op. + */ + void startSession() { + shouldUnregisterOnTimeout = false; + + final Application hostApplication = + ServiceProvider.getInstance().getAppContextService().getApplication(); + + if (hostApplication == null || !AssuranceUtil.isDebugBuild(hostApplication)) { + Log.warning( + Assurance.LOG_TAG, + LOG_TAG, + "startSession() API is available only on debug builds."); + return; + } + + final Activity currentActivity = + ServiceProvider.getInstance().getAppContextService().getCurrentActivity(); + if (currentActivity == null) { + Log.debug(Assurance.LOG_TAG, LOG_TAG, "No foreground activity to launch quick flow."); + return; + } + + if (assuranceSessionOrchestrator.getActiveSession() != null) { + Log.debug( + Assurance.LOG_TAG, + LOG_TAG, + "Unable to start Assurance session. Session already exists"); + return; + } + + final Intent quickConnectIntent = + new Intent(hostApplication, AssuranceQuickConnectActivity.class); + quickConnectIntent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION); + quickConnectIntent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + currentActivity.startActivity(quickConnectIntent); + } + // ======================================================================================== // overridden methods - Extension class // ======================================================================================== @@ -302,23 +347,33 @@ void handleWildcardEvent(final Event event) { assuranceSessionOrchestrator.queueEvent(assuranceEvent); } - void handleAssuranceRequestContent(Event event) { - String sessionURL = + void handleAssuranceRequestContent(final Event event) { + final Map eventData = event.getEventData(); + + // Check if this is a quick connect session + final boolean isQuickConnectEvent = + DataReader.optBoolean( + eventData, AssuranceConstants.SDKEventDataKey.IS_QUICK_CONNECT, false); + if (isQuickConnectEvent) { + startSession(); + return; + } + + // Check if this is a deeplink session + final String sessionURL = DataReader.optString( - event.getEventData(), - AssuranceConstants.SDKEventDataKey.START_SESSION_URL, - ""); + eventData, AssuranceConstants.SDKEventDataKey.START_SESSION_URL, ""); - if ("".equals(sessionURL)) { - Log.warning( - Assurance.LOG_TAG, - LOG_TAG, - "Unable to process start session event. could find start session URL in the" - + " event"); + if (!StringUtils.isNullOrEmpty(sessionURL)) { + startSession(sessionURL); return; } - startSession(sessionURL); + Log.warning( + Assurance.LOG_TAG, + LOG_TAG, + "Unable to process start session event. Could find start session URL" + + " or quick connect flag in the event"); } // ======================================================================================== @@ -412,7 +467,7 @@ private void shutDownAssurance() { LOG_TAG, "Timeout - Assurance did not receive deeplink to start Assurance session within 5" + " seconds. Shutting down Assurance extension"); - assuranceSessionOrchestrator.terminateSession(); + assuranceSessionOrchestrator.terminateSession(true); } /** diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceFloatingButton.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceFloatingButton.java index 6eb68b3..94e737f 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceFloatingButton.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceFloatingButton.java @@ -80,7 +80,7 @@ public AssuranceFloatingButtonView.Graphic getCurrentGraphic() { private void display(final float x, final float y, final Activity activity) { // Make sure we don't overlay a assurance ui view with the floating button... hilarity will // ensue. - if (activity instanceof AssuranceFullScreenTakeoverActivity) { + if (AssuranceUtil.isAssuranceActivity(activity)) { Log.trace( Assurance.LOG_TAG, LOG_TAG, @@ -241,6 +241,7 @@ public void run() { floatingButton.setOnPositionChangedListener(null); floatingButton.setOnClickListener(null); floatingButton.setVisibility(GONE); + rootViewGroup.removeView(floatingButton); } else { Log.debug( Assurance.LOG_TAG, @@ -338,9 +339,7 @@ private void manageButtonDisplayForActivity(final Activity activity) { } else { // Show the button (create new if does not exist for this activity) if (managedButtonViews.get(activityClassName) == null - && !AssuranceFullScreenTakeoverActivity.class - .getSimpleName() - .equalsIgnoreCase(activity.getClass().getSimpleName())) { + && !AssuranceUtil.isAssuranceActivity(activity)) { // We do not have an existing button showing, create one Log.trace(Assurance.LOG_TAG, LOG_TAG, "Creating floating button for " + activity); final AssuranceFloatingButtonView newButton = diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssurancePinCodeEntryURLProvider.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssurancePinCodeEntryProvider.java similarity index 91% rename from code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssurancePinCodeEntryURLProvider.java rename to code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssurancePinCodeEntryProvider.java index d25da39..28ce7b3 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssurancePinCodeEntryURLProvider.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssurancePinCodeEntryProvider.java @@ -20,21 +20,22 @@ import java.io.InputStream; import java.util.Scanner; -class AssurancePinCodeEntryURLProvider - implements AssuranceFullScreenTakeover.FullScreenTakeoverCallbacks { +class AssurancePinCodeEntryProvider + implements AssuranceFullScreenTakeover.FullScreenTakeoverCallbacks, + SessionAuthorizingPresentation { private static final String LOG_TAG = "AssurancePinCodeEntryURLProvider"; private static final String MESSAGE_HOST_CANCEL = "cancel"; private static final String MESSAGE_HOST_CONFIRM = "confirm"; private static final String HTML_QUERY_KEY_PIN_CODE = "code"; private final AssuranceSessionOrchestrator.ApplicationHandle applicationHandle; - Runnable deferredActivityRunnable; + private Runnable deferredActivityRunnable; private final AssuranceStateManager assuranceStateManager; private AssuranceFullScreenTakeover pinCodeTakeover; private AssuranceSessionOrchestrator.SessionUIOperationHandler uiOperationHandler; private boolean isDisplayed; - AssurancePinCodeEntryURLProvider( + AssurancePinCodeEntryProvider( final AssuranceSessionOrchestrator.ApplicationHandle applicationHandle, final AssuranceSessionOrchestrator.SessionUIOperationHandler uiOperationHandler, final AssuranceStateManager assuranceStateManager) { @@ -44,17 +45,28 @@ class AssurancePinCodeEntryURLProvider this.uiOperationHandler = uiOperationHandler; } + @Override public boolean isDisplayed() { return isDisplayed; } - void launchPinDialog() { + @Override + public void reorderToFront() { + if (deferredActivityRunnable != null) { + Log.debug(Assurance.LOG_TAG, LOG_TAG, "Deferred connection dialog found, triggering."); + deferredActivityRunnable.run(); + deferredActivityRunnable = null; + } + } + + @Override + public void showAuthorization() { // if we already have a pincode takeover, we should exit. if (pinCodeTakeover != null) { return; } - final AssurancePinCodeEntryURLProvider thisRef = this; + final AssurancePinCodeEntryProvider thisRef = this; // Load and launch pin code entry dialog new Thread( @@ -171,12 +183,14 @@ public void run() { .start(); } + @Override public void onConnecting() { if (pinCodeTakeover != null) { pinCodeTakeover.runJavascript("showLoading()"); } } + @Override public void onConnectionSucceeded() { if (pinCodeTakeover != null) { pinCodeTakeover.remove(); @@ -189,14 +203,15 @@ public void onConnectionFinished() { } } + @Override public void onConnectionFailed( - final AssuranceConstants.AssuranceSocketError socketError, + final AssuranceConstants.AssuranceConnectionError connectionError, final boolean shouldShowRetry) { pinCodeTakeover.runJavascript( "showError('" - + socketError.getError() + + connectionError.getError() + "', '" - + socketError.getErrorDescription() + + connectionError.getDescription() + "', " + shouldShowRetry + ")"); @@ -205,7 +220,7 @@ public void onConnectionFailed( LOG_TAG, String.format( "Assurance connection closed. Reason: %s, Description: %s", - socketError.getError(), socketError.getErrorDescription())); + connectionError.getError(), connectionError.getDescription())); } @Override @@ -237,9 +252,10 @@ public boolean onURLTriggered(final String url) { LOG_TAG, String.format( "%s %s", - AssuranceConstants.AssuranceSocketError.NO_ORGID.getError(), - AssuranceConstants.AssuranceSocketError.NO_ORGID.getError())); - onConnectionFailed(AssuranceConstants.AssuranceSocketError.NO_ORGID, true); + AssuranceConstants.AssuranceConnectionError.NO_ORG_ID.getError(), + AssuranceConstants.AssuranceConnectionError.NO_ORG_ID + .getDescription())); + onConnectionFailed(AssuranceConstants.AssuranceConnectionError.NO_ORG_ID, true); return true; } diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceQuickConnectActivity.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceQuickConnectActivity.kt new file mode 100644 index 0000000..fdf5460 --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceQuickConnectActivity.kt @@ -0,0 +1,256 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import android.app.Activity +import android.graphics.Color +import android.graphics.LightingColorFilter +import android.os.Bundle +import android.view.View +import android.view.Window +import android.widget.ProgressBar +import android.widget.TextView +import com.adobe.marketing.mobile.Assurance +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceSession.AssuranceSessionStatusListener +import com.adobe.marketing.mobile.services.Log +import java.util.concurrent.Executors + +class AssuranceQuickConnectActivity : Activity() { + + private companion object { + private const val LOG_SOURCE = "AssuranceQuickConnectActivity" + } + + private lateinit var connectButtonView: View + private lateinit var connectButton: ProgressButton + private lateinit var cancelButtonView: View + private lateinit var errorDetailTextView: TextView + private lateinit var errorTitleTextView: TextView + private val sessionStatusListener = object : AssuranceSessionStatusListener { + override fun onSessionConnected() { + Log.trace(Assurance.LOG_TAG, LOG_SOURCE, "Session Connected. Finishing activity.") + finish() + } + + override fun onSessionTerminated(error: AssuranceConnectionError?) { + Log.trace(Assurance.LOG_TAG, LOG_SOURCE, "Session terminated.") + error?.let { + showError(error) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + requestWindowFeature(Window.FEATURE_NO_TITLE) + hideSystemUI() + setContentView(R.layout.quick_connect_screen_layout) + + val assuranceStateManager = AssuranceComponentRegistry.assuranceStateManager + val sessionUIOperationHandler = AssuranceComponentRegistry.sessionUIOperationHandler + + if (!AssuranceUtil.isDebugBuild(application)) { + Log.warning(Assurance.LOG_TAG, LOG_SOURCE, "QuickConnect cannot be initiated. Application is not in debug mode.") + finish() + return + } + + if (assuranceStateManager == null || sessionUIOperationHandler == null) { + Log.warning(Assurance.LOG_TAG, LOG_SOURCE, "Required components for QuickConnect are unavailable.") + finish() + return + } + + // Initialize UI elements for QuickConnect screen + setupQuickConnectScreen() + + val quickConnectCallback = object : QuickConnectCallback { + override fun onError(error: AssuranceConnectionError) { + showError(error) + } + + override fun onSuccess(sessionUUID: String, token: String) { + sessionUIOperationHandler.onQuickConnect(sessionUUID, token, sessionStatusListener) + } + } + + val quickConnectManager = QuickConnectManager( + assuranceStateManager, + Executors.newSingleThreadScheduledExecutor(), + quickConnectCallback + ) + + configureConnectButton(connectButtonView, quickConnectManager) + configureCancelButton(cancelButtonView, quickConnectManager) + } + + /** + * Initializes components of the Quick Connect UI. + */ + private fun setupQuickConnectScreen() { + connectButtonView = findViewById(R.id.connectButton) + connectButton = ProgressButton("Connect", connectButtonView) + + cancelButtonView = findViewById(R.id.cancelButton).also { + it.setBackgroundResource(R.drawable.shape_custom_button_outlined) + it.findViewById(R.id.buttonText).also { button -> + button.text = getString(R.string.quick_connect_button_cancel) + } + + it.findViewById(R.id.progressBar).also { progressBar -> + progressBar.visibility = View.GONE + } + } + + errorTitleTextView = findViewById(R.id.errorTitleTextView).also { + it.visibility = View.GONE + } + + errorDetailTextView = findViewById(R.id.errorDetailTextView).also { + it.visibility = View.GONE + } + } + + /** + * Configures the "Connect" button on the UI to trigger QuickConnect based on current state. + */ + private fun configureConnectButton( + connectionButtonView: View, + quickConnectManager: QuickConnectManager + ) { + connectionButtonView.setOnClickListener { + when (connectButton.state) { + ProgressButton.State.IDLE -> { + connectButton.waiting() + quickConnectManager.registerDevice() + } + + ProgressButton.State.RETRY -> { + hideError() + connectButton.waiting() + quickConnectManager.registerDevice() + } + + else -> { + // All other states are affected internally based on connection. + // So do nothing. + } + } + } + } + + /** + * Configures the "Connect" button on the UI to cancel ongoing QuickConnect + * and finish the activity. + */ + private fun configureCancelButton( + cancelButtonView: View, + quickConnectManager: QuickConnectManager + ) { + cancelButtonView.setOnClickListener { + quickConnectManager.cancel() + AssuranceComponentRegistry.sessionUIOperationHandler?.onCancel() + finish() + } + } + + /** + * Hides the contents of [errorDetailTextView] and [errorDetailTextView] + */ + private fun hideError() { + runOnUiThread { + errorTitleTextView.text = "" + errorTitleTextView.visibility = View.GONE + errorDetailTextView.text = "" + errorDetailTextView.visibility = View.GONE + } + } + + /** + * Displays [errorDetailTextView] and [errorDetailTextView] based on the + * [connectionError] + */ + private fun showError(connectionError: AssuranceConnectionError) { + runOnUiThread { + errorTitleTextView.text = connectionError.error + errorTitleTextView.visibility = View.VISIBLE + errorDetailTextView.text = connectionError.description + errorDetailTextView.visibility = View.VISIBLE + if (connectionError.isRetryable) { + connectButton.retry() + } else { + connectButtonView.visibility = View.GONE + } + } + } + + private fun hideSystemUI() { + this.window + .decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } + + /** + * A wrapper for managing states of a "progress button" which is essentially a View composed of + * a TextView and a progress spinner. + */ + private class ProgressButton(private val initialLabel: String, private val view: View) { + private val progressBar: ProgressBar = view.findViewById(R.id.progressBar) + .also { it.visibility = View.GONE } + private val text: TextView = view.findViewById(R.id.buttonText) + .also { it.text = initialLabel } + internal var state: State = State.IDLE + private set + + init { + idle() + } + + internal enum class State { + IDLE, + WAITING, + RETRY + } + + fun idle() { + state = State.IDLE + text.text = view.resources.getString(R.string.quick_connect_button_connect) + progressBar.visibility = View.GONE + view.setBackgroundResource(R.drawable.shape_custom_button_filled) + } + + fun waiting() { + state = State.WAITING + text.text = view.resources.getString(R.string.quick_connect_button_waiting) + + // Using a ColorFilter instead of a tint because it is not supported for Api 19 + progressBar.indeterminateDrawable.colorFilter = + LightingColorFilter(Color.rgb(6, 142, 228), Color.TRANSPARENT) + progressBar.visibility = View.VISIBLE + view.setBackgroundResource(R.drawable.shape_custom_button_inactive) + } + + fun retry() { + state = State.RETRY + text.text = view.resources.getString(R.string.quick_connect_button_retry) + progressBar.visibility = View.GONE + view.setBackgroundResource(R.drawable.shape_custom_button_filled) + } + } +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSession.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSession.java index f9785c5..50c2b37 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSession.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSession.java @@ -17,6 +17,7 @@ import android.os.Handler; import android.os.HandlerThread; import android.os.Looper; +import androidx.annotation.Nullable; import com.adobe.marketing.mobile.Assurance; import com.adobe.marketing.mobile.services.Log; import com.adobe.marketing.mobile.util.StringUtils; @@ -58,6 +59,7 @@ class AssuranceSession implements AssuranceWebViewSocketHandler { private final AssuranceSessionPresentationManager assuranceSessionPresentationManager; private final Set sessionStatusListeners; private final AssuranceConnectionDataStore connectionDataStore; + private final SessionAuthorizingPresentation.Type authorizingPresentationType; private final InboundEventQueueWorker.InboundQueueEventListener inboundQueueEventListener = new InboundEventQueueWorker.InboundQueueEventListener() { @@ -87,8 +89,13 @@ interface AssuranceSessionStatusListener { /** Callback indicating that the AssuranceSession is connected. */ void onSessionConnected(); - /** Callback indicating that the AssuranceSession is disconnected. */ - void onSessionTerminated(); + /** + * Callback indicating that the AssuranceSession is disconnected. + * + * @param error an optional {@code AssuranceConnectionError} if the session was terminated + * due to an error. + */ + void onSessionTerminated(@Nullable AssuranceConstants.AssuranceConnectionError error); } AssuranceSession( @@ -99,7 +106,9 @@ interface AssuranceSessionStatusListener { final AssuranceConnectionDataStore connectionDataStore, final AssuranceSessionOrchestrator.SessionUIOperationHandler uiOperationHandler, final List plugins, - final List bufferedEvents) { + final List bufferedEvents, + final SessionAuthorizingPresentation.Type authorizingPresentationType, + final AssuranceSessionStatusListener authorizingPresentationDelegate) { this.assuranceStateManager = assuranceStateManager; this.applicationHandle = applicationHandle; @@ -107,10 +116,15 @@ interface AssuranceSessionStatusListener { this.sessionId = sessionId; this.sessionStatusListeners = new HashSet<>(); this.connectionDataStore = connectionDataStore; + this.authorizingPresentationType = authorizingPresentationType; assuranceSessionPresentationManager = new AssuranceSessionPresentationManager( - assuranceStateManager, uiOperationHandler, applicationHandle); + assuranceStateManager, + uiOperationHandler, + applicationHandle, + authorizingPresentationType, + authorizingPresentationDelegate); pluginManager = new AssurancePluginManager(this); @@ -165,7 +179,7 @@ void connect(final String pin) { return; } - // If PIN is available, we want to connect directly without PIN prompt. + // If PIN is available, we want to connect directly without authorization prompt. Log.debug(Assurance.LOG_TAG, LOG_TAG, "Found stored. Connecting session directly"); assuranceSessionPresentationManager.onSessionConnecting(); @@ -276,6 +290,15 @@ String getSessionId() { return sessionId; } + /** + * Retrieves the type of the authorizing presentation that is associated with this session. + * + * @return the type of the authorizing presentation that is associated with this session. + */ + SessionAuthorizingPresentation.Type getAuthorizingPresentationType() { + return authorizingPresentationType; + } + @Override public void onSocketConnected(final AssuranceWebViewSocket socket) { Log.debug(Assurance.LOG_TAG, LOG_TAG, "Websocket connected."); @@ -356,7 +379,7 @@ public void onSocketDisconnected( clearSessionData(); assuranceSessionPresentationManager.onSessionDisconnected(closeCode); pluginManager.onSessionTerminated(); - notifyTerminationAndRemoveStatusListeners(); + notifyTerminationAndRemoveStatusListeners(null); break; case AssuranceConstants.SocketCloseCode.ORG_MISMATCH: @@ -370,7 +393,8 @@ public void onSocketDisconnected( // option and UI will be dismissed anyhow pluginManager.onSessionDisconnected(closeCode); pluginManager.onSessionTerminated(); - notifyTerminationAndRemoveStatusListeners(); + notifyTerminationAndRemoveStatusListeners( + AssuranceConstants.SocketCloseCode.toAssuranceConnectionError(closeCode)); break; default: @@ -382,19 +406,27 @@ public void onSocketDisconnected( errorReason, closeCode)); outboundEventQueueWorker.block(); assuranceSessionPresentationManager.onSessionDisconnected(closeCode); - long delayBeforeReconnect = - isAttemptingToReconnect ? SOCKET_RECONNECT_TIME_DELAY : 0L; - // If the disconnect happens because of abnormal close code. And if we are - // attempting to reconnect for the first time then, - // 1. Make an appropriate UI log. - // 2. Change the button graphics to gray out. - // 3. Notify plugins on disconnect with abnormal close code. - // 4. Attempt to reconnect with appropriate time delay. + // If the disconnect happens because of abnormal close code when the + // authorizing presentation is not active, and if we are + // attempting to reconnect for the first time then : + // 1. Make an appropriate UI log. + // 2. Change the button graphics to gray out. + // 3. Notify plugins on disconnect with abnormal close code. + // 4. Attempt to reconnect with appropriate time delay. + // Otherwise, notify the plugins about disconnection and await a retry click + // from the UI. if (!isAttemptingToReconnect) { + pluginManager.onSessionDisconnected(closeCode); + + if (assuranceSessionPresentationManager.isAuthorizingPresentationActive()) { + // Do not retry of authorizing presentation is active. Retry should only be + // done from the UI in this case. + return; + } + isAttemptingToReconnect = true; assuranceSessionPresentationManager.onSessionReconnecting(); - pluginManager.onSessionDisconnected(closeCode); Log.warning( Assurance.LOG_TAG, LOG_TAG, @@ -402,6 +434,8 @@ public void onSocketDisconnected( } // attempt to reconnect after a certain delay through reconnect handler + long delayBeforeReconnect = + isAttemptingToReconnect ? SOCKET_RECONNECT_TIME_DELAY : 0L; socketReconnectHandler.postDelayed( new Runnable() { @Override @@ -568,10 +602,11 @@ private void onStartForwardingEvent() { * collection of AssuranceSession due to any references to the listener exceeding session * lifespan. */ - private void notifyTerminationAndRemoveStatusListeners() { + private void notifyTerminationAndRemoveStatusListeners( + @Nullable AssuranceConstants.AssuranceConnectionError error) { for (final AssuranceSessionStatusListener listener : sessionStatusListeners) { if (listener != null) { - listener.onSessionTerminated(); + listener.onSessionTerminated(error); unregisterStatusListener(listener); } } diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestrator.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestrator.java index 3c73e95..ece9e20 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestrator.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestrator.java @@ -76,20 +76,61 @@ public void onConnect(final String pin) { Assurance.LOG_TAG, LOG_TAG, "Null/Empty PIN recorded. Cannot connect to session."); - terminateSession(); + terminateSession(true); return; } session.connect(pin); } + @Override + public void onQuickConnect( + @NonNull String sessionId, + @NonNull String token, + @NonNull AssuranceSession.AssuranceSessionStatusListener listener) { + + if (session != null) { + // There is an active session, check the authorizing presentation to + // determine if a new session can be started or not. + if (session.getAuthorizingPresentationType() + == SessionAuthorizingPresentation.Type.PIN) { + // This should never happen in an ideal situation. + Log.warning( + Assurance.LOG_TAG, + LOG_TAG, + "Cannot start Quick Connect session. An active PIN based" + + " session exists."); + listener.onSessionTerminated( + AssuranceConstants.AssuranceConnectionError.UNEXPECTED_ERROR); + return; + } else { + // This is a Quick Connect retry scenario. Disconnect existing session + // without clearing the buffered events for this retry scenario and + // connect again. + Log.debug( + Assurance.LOG_TAG, + LOG_TAG, + "Disconnecting active QuickConnect session and recreating."); + + terminateSession(false); + } + } + + createSession( + sessionId, + AssuranceConstants.AssuranceEnvironment.PROD, + token, + listener, + SessionAuthorizingPresentation.Type.QUICK_CONNECT); + } + @Override public void onDisconnect() { Log.debug( Assurance.LOG_TAG, LOG_TAG, "On Disconnect clicked. Disconnecting session."); - terminateSession(); + terminateSession(true); } @Override @@ -98,7 +139,7 @@ public void onCancel() { Assurance.LOG_TAG, LOG_TAG, "On Cancel Clicked. Disconnecting session."); - terminateSession(); + terminateSession(true); } }; @@ -122,14 +163,15 @@ public void onSessionConnected() { } @Override - public void onSessionTerminated() { + public void onSessionTerminated( + final AssuranceConstants.AssuranceConnectionError error) { // In case of a user initiated AssuranceSessionOrchestrator#terminateSession() // will unregister the listener against the session // before disconnecting it. So this callback is never invoked in that flow. // However, in case of a disconnection initiated by the service, we need to // cleanup references to the // active session to release resources. - terminateSession(); + terminateSession(true); } }; @@ -167,6 +209,8 @@ public void onSessionTerminated() { this.sessionCreator = sessionCreator; application.registerActivityLifecycleCallbacks(activityLifecycleObserver); + AssuranceComponentRegistry.INSTANCE.initialize( + assuranceStateManager, sessionUIOperationHandler); } /** @@ -178,11 +222,16 @@ public void onSessionTerminated() { * connected to. * @param code the PIN code with which the {@code AssuranceSession} should be authenticated * with. + * @param statusListener an optional status listener that can be attached to the session + * @param authorizingPresentationType the type of the authorization UI to be shown for the + * session */ synchronized void createSession( - final String sessionId, - final AssuranceConstants.AssuranceEnvironment environment, - final String code) { + @NonNull final String sessionId, + @NonNull final AssuranceConstants.AssuranceEnvironment environment, + @Nullable final String code, + @Nullable final AssuranceSession.AssuranceSessionStatusListener statusListener, + @NonNull final SessionAuthorizingPresentation.Type authorizingPresentationType) { if (session != null) { Log.error( Assurance.LOG_TAG, @@ -201,9 +250,11 @@ synchronized void createSession( plugins, connectionURLStore, applicationHandle, - outboundEventBuffer); + outboundEventBuffer, + statusListener, + authorizingPresentationType); - // register the session status listener to manage the outboundEventBuffer. + // register the session status listener for orchestrator to manage the outboundEventBuffer. session.registerStatusListener(sessionStatusListener); // Immediately share the extension state. @@ -213,15 +264,20 @@ synchronized void createSession( session.connect(code); } - /** Dissolve the active session (if one exists) and its associated states. */ - synchronized void terminateSession() { + /** + * Dissolve the active session (if one exists) and its associated states. + * + * @param purgeBuffer flag indicating whether or not to clear buffered events. + */ + synchronized void terminateSession(final boolean purgeBuffer) { Log.debug( Assurance.LOG_TAG, LOG_TAG, - "Terminating active session. Clearing the queued" - + "events and purging Assurance shared state"); + "Terminating active session purging Assurance shared state"); + + if (purgeBuffer && outboundEventBuffer != null) { + Log.debug(Assurance.LOG_TAG, LOG_TAG, "Clearing the queued events."); - if (outboundEventBuffer != null) { outboundEventBuffer.clear(); outboundEventBuffer = null; } @@ -240,7 +296,7 @@ synchronized void terminateSession() { * disconnected by the user via a valid connection url. * * @return true if there exists a valid connection url that can be reconnected to, false if a - * valist connection url does not exist. + * valid connection url does not exist. */ boolean reconnectToStoredSession() { final String connectionURL = connectionURLStore.getStoredConnectionURL(); @@ -277,7 +333,7 @@ boolean reconnectToStoredSession() { "Initializing Assurance session. %s using stored connection details:%s ", sessionId, connectionURL); - createSession(sessionId, environment, pin); + createSession(sessionId, environment, pin, null, SessionAuthorizingPresentation.Type.PIN); return true; } @@ -333,6 +389,19 @@ interface SessionUIOperationHandler { /** Invoked when an UI operation corresponding to session connection attempt is made. */ void onConnect(final String pin); + /** + * Invoked when a UI/gesture corresponding to QuickConnect is made. + * + * @param sessionId the session id of the Assurance quick connect session + * @param token token/pin to authorize the session + * @param listener an {@code AssuranceSessionStatusListener} to identify the status of the + * session created by quick connect + */ + void onQuickConnect( + @NonNull final String sessionId, + @NonNull final String token, + @NonNull final AssuranceSession.AssuranceSessionStatusListener listener); + /** Invoked when an UI operation corresponding to session disconnect is made. */ void onDisconnect(); @@ -507,7 +576,10 @@ AssuranceSession create( final List plugins, final AssuranceConnectionDataStore connectionURLStore, final ApplicationHandle applicationHandle, - final List outboundEventBuffer) { + final List outboundEventBuffer, + final AssuranceSession.AssuranceSessionStatusListener + authorizingPresentationListener, + final SessionAuthorizingPresentation.Type authorizingPresentationType) { return new AssuranceSession( applicationHandle, assuranceStateManager, @@ -516,7 +588,9 @@ AssuranceSession create( connectionURLStore, sessionUIOperationHandler, plugins, - outboundEventBuffer); + outboundEventBuffer, + authorizingPresentationType, + authorizingPresentationListener); } } } diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManager.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManager.java index bee7961..0c9c777 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManager.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManager.java @@ -27,12 +27,14 @@ class AssuranceSessionPresentationManager { private final AssuranceSessionOrchestrator.ApplicationHandle applicationHandle; private AssuranceFloatingButton button; private AssuranceConnectionStatusUI statusUI; - private AssurancePinCodeEntryURLProvider urlProvider; + private SessionAuthorizingPresentation authorizingPresentation; AssuranceSessionPresentationManager( final AssuranceStateManager assuranceStateManager, final AssuranceSessionOrchestrator.SessionUIOperationHandler uiOperationHandler, - final AssuranceSessionOrchestrator.ApplicationHandle applicationHandle) { + final AssuranceSessionOrchestrator.ApplicationHandle applicationHandle, + final SessionAuthorizingPresentation.Type authorizingPresentationType, + final AssuranceSession.AssuranceSessionStatusListener authorizingPresentationDelegate) { this.applicationHandle = applicationHandle; statusUI = new AssuranceConnectionStatusUI(uiOperationHandler, applicationHandle); @@ -49,9 +51,14 @@ public void onClick(View v) { } }); - urlProvider = - new AssurancePinCodeEntryURLProvider( - applicationHandle, uiOperationHandler, assuranceStateManager); + if (authorizingPresentationType == SessionAuthorizingPresentation.Type.PIN) { + authorizingPresentation = + new AssurancePinCodeEntryProvider( + applicationHandle, uiOperationHandler, assuranceStateManager); + } else { + authorizingPresentation = + new QuickConnectAuthorizingPresentation(authorizingPresentationDelegate); + } } /** @@ -69,15 +76,15 @@ void logLocalUI( /** Shows the UI elements that are required when a session is initialized. */ void onSessionInitialized() { - if (urlProvider != null) { - urlProvider.launchPinDialog(); + if (authorizingPresentation != null) { + authorizingPresentation.showAuthorization(); } } /** Shows the UI elements that are required when a session connection is in progress. */ void onSessionConnecting() { - if (urlProvider != null) { - urlProvider.onConnecting(); + if (authorizingPresentation != null) { + authorizingPresentation.onConnecting(); } } @@ -86,8 +93,8 @@ void onSessionConnecting() { * connection has been successfully established. */ void onSessionConnected() { - if (urlProvider != null) { - urlProvider.onConnectionSucceeded(); + if (authorizingPresentation != null) { + authorizingPresentation.onConnectionSucceeded(); } if (button != null) { @@ -101,35 +108,20 @@ void onSessionConnected() { /** Shows the UI elements that are required when a session connection has been disconnected. */ void onSessionDisconnected(final int closeCode) { - switch (closeCode) { - case AssuranceConstants.SocketCloseCode.NORMAL: - cleanupUIElements(); - break; - - case AssuranceConstants.SocketCloseCode.ORG_MISMATCH: - displayError(AssuranceConstants.AssuranceSocketError.ORGID_MISMATCH, closeCode); - break; - - case AssuranceConstants.SocketCloseCode.CLIENT_ERROR: - displayError(AssuranceConstants.AssuranceSocketError.CLIENT_ERROR, closeCode); - break; - - case AssuranceConstants.SocketCloseCode.CONNECTION_LIMIT: - displayError(AssuranceConstants.AssuranceSocketError.CONNECTION_LIMIT, closeCode); - break; - - case AssuranceConstants.SocketCloseCode.EVENT_LIMIT: - displayError(AssuranceConstants.AssuranceSocketError.EVENT_LIMIT, closeCode); - break; - - case AssuranceConstants.SocketCloseCode.SESSION_DELETED: - displayError(AssuranceConstants.AssuranceSocketError.SESSION_DELETED, closeCode); - break; - - default: - displayError( - AssuranceConstants.AssuranceSocketError.GENERIC_ERROR, - AssuranceConstants.SocketCloseCode.ABNORMAL); + if (closeCode == AssuranceConstants.SocketCloseCode.NORMAL) { + cleanupUIElements(); + return; + } + + final AssuranceConstants.AssuranceConnectionError assuranceConnectionError = + AssuranceConstants.SocketCloseCode.toAssuranceConnectionError(closeCode); + + if (assuranceConnectionError != null) { + displayError(assuranceConnectionError, closeCode); + } else { + displayError( + AssuranceConstants.AssuranceConnectionError.GENERIC_ERROR, + AssuranceConstants.SocketCloseCode.ABNORMAL); } } @@ -197,17 +189,8 @@ void onActivityResumed(final Activity activity) { button.onActivityResumed(activity); } - if (urlProvider != null) { - final Runnable deferredRunnable = urlProvider.deferredActivityRunnable; - - if (deferredRunnable != null) { - Log.debug( - Assurance.LOG_TAG, - LOG_TAG, - "Session Activity Hook - Deferred connection dialog found, triggering."); - deferredRunnable.run(); - urlProvider.deferredActivityRunnable = null; - } + if (authorizingPresentation != null) { + authorizingPresentation.reorderToFront(); } } @@ -224,13 +207,13 @@ void onActivityDestroyed(final Activity activity) { } private void displayError( - final AssuranceConstants.AssuranceSocketError socketError, final int closeCode) { - if (urlProvider != null && urlProvider.isDisplayed()) { - // If this is an unhandled/abnormal error while the PIN screen is on, set the retry flag - // to true. - // Else the "Cancel" button on the PIN screen will allow socket disconnection and - // cleaning up of the UI Elements. - urlProvider.onConnectionFailed( + final AssuranceConstants.AssuranceConnectionError socketError, final int closeCode) { + if (authorizingPresentation != null && authorizingPresentation.isDisplayed()) { + // If this is an unhandled/abnormal error while the authorizing screen is on, + // set the retry flag to true. + // In case of a non retryable error the "Cancel" button on the authorizing screen will + // allow socket disconnection and cleaning up of the UI Elements. + authorizingPresentation.onConnectionFailed( socketError, closeCode == AssuranceConstants.SocketCloseCode.ABNORMAL); } else { if (closeCode == AssuranceConstants.SocketCloseCode.ABNORMAL) { @@ -252,8 +235,8 @@ private void cleanupUIElements() { button = null; } - if (urlProvider != null) { - urlProvider = null; + if (authorizingPresentation != null) { + authorizingPresentation = null; } if (statusUI != null) { @@ -262,7 +245,7 @@ private void cleanupUIElements() { } } - private void showErrorDisplay(final AssuranceConstants.AssuranceSocketError socketError) { + private void showErrorDisplay(final AssuranceConstants.AssuranceConnectionError socketError) { final Activity currentActivity = applicationHandle.getCurrentActivity(); if (currentActivity == null) { @@ -282,7 +265,7 @@ private void showErrorDisplay(final AssuranceConstants.AssuranceSocketError sock AssuranceConstants.IntentExtraKey.ERROR_NAME, socketError.getError()); errorScreen.putExtra( AssuranceConstants.IntentExtraKey.ERROR_DESCRIPTION, - socketError.getErrorDescription()); + socketError.getDescription()); currentActivity.startActivity(errorScreen); currentActivity.overridePendingTransition(0, 0); } catch (final ActivityNotFoundException ex) { @@ -293,4 +276,11 @@ private void showErrorDisplay(final AssuranceConstants.AssuranceSocketError sock ex.getLocalizedMessage()); } } + + boolean isAuthorizingPresentationActive() { + if (authorizingPresentation != null) { + return authorizingPresentation.isDisplayed(); + } + return false; + } } diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceUtil.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceUtil.java index 6b42efe..ddbd4a1 100644 --- a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceUtil.java +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/AssuranceUtil.java @@ -12,6 +12,9 @@ package com.adobe.marketing.mobile.assurance; +import android.app.Activity; +import android.app.Application; +import android.content.pm.ApplicationInfo; import android.net.ParseException; import android.net.Uri; import androidx.annotation.NonNull; @@ -193,6 +196,30 @@ static boolean isSafe(final String url) { return true; } + /** + * Check if the host application integrated with Assurance is a debug build. + * + * @param application the application to be validated + * @return true if the application is debuggable; false otherwise + */ + static boolean isDebugBuild(@NonNull final Application application) { + return ((application.getApplicationContext().getApplicationInfo().flags + & ApplicationInfo.FLAG_DEBUGGABLE) + != 0); + } + + /** + * Check if an activity belongs to Assurance. + * + * @param activity the activity to check + * @return true if an activity is an Assurance activity; false otherwise + */ + static boolean isAssuranceActivity(@NonNull final Activity activity) { + return (activity instanceof AssuranceFullScreenTakeoverActivity + || activity instanceof AssuranceQuickConnectActivity + || activity instanceof AssuranceErrorDisplayActivity); + } + /** * Check if the provided scheme is valid * diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectAuthorizingPresentation.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectAuthorizingPresentation.kt new file mode 100644 index 0000000..a8148a1 --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectAuthorizingPresentation.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceSession.AssuranceSessionStatusListener +import com.adobe.marketing.mobile.services.ServiceProvider +import java.lang.ref.WeakReference + +/** + * Manages authorizing UI for QuickConnect. Due to the authorization happening before session + * creation for QuickConnect, this class is a proxy for relaying the session status to the + * AssuranceQuickConnectActivity. + */ +internal class QuickConnectAuthorizingPresentation( + sessionStatusListener: AssuranceSessionStatusListener? +) : SessionAuthorizingPresentation { + + /** + * A weak reference to the status listener of the AssuranceQuickConnectActivity. + */ + private val presentationDelegate: WeakReference = + WeakReference(sessionStatusListener) + + override fun isDisplayed(): Boolean { + return ServiceProvider.getInstance().appContextService.currentActivity is AssuranceQuickConnectActivity + } + + override fun reorderToFront() { + // no-op quick connect UI is always launched on demand + // so there is no reason to re-order + } + + override fun showAuthorization() { + // no-op + // Unlike a PIN authorized session (where the session and its sessionId exists before + // authorization) the quick connect authorization happens before a session is created. + } + + override fun onConnecting() { + // no op - managed internally by the AssuranceQuickConnectActivity + } + + override fun onConnectionSucceeded() { + presentationDelegate.get()?.onSessionConnected() + } + + override fun onConnectionFailed(connectionError: AssuranceConnectionError, shouldShowRetry: Boolean) { + presentationDelegate.get()?.onSessionTerminated(connectionError) + } +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectCallback.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectCallback.java new file mode 100644 index 0000000..11b78b8 --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectCallback.java @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance; + + +import androidx.annotation.NonNull; + +/** + * A callback that allows components integrating with {@link QuickConnectManager} to receive a + * notification about the status of the QuickConnect connection request. + */ +interface QuickConnectCallback { + + /** + * Invoked when an error occurs when during QuickConnect connection workflow. + * + * @param error an {@code AssuranceConnectionError} that occurred resulting in quick connect + * workflow cancellation + */ + void onError(@NonNull AssuranceConstants.AssuranceConnectionError error); + + /** + * Invoked with the quick connect session details when the QuickConnect workflow is successful. + * These details can be used to establish a connection with the session. + * + * @param sessionUUID the sessionId associated with the quick connect session + * @param token the authorizing token for the quick connect session + */ + void onSuccess(@NonNull String sessionUUID, @NonNull String token); +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceCreator.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceCreator.kt new file mode 100644 index 0000000..1714ccd --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceCreator.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import androidx.annotation.VisibleForTesting +import com.adobe.marketing.mobile.AdobeCallback +import com.adobe.marketing.mobile.Assurance +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceConstants.QuickConnect +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.NetworkingConstants +import com.adobe.marketing.mobile.services.ServiceProvider +import org.json.JSONObject +import javax.net.ssl.HttpsURLConnection + +/** + * Responsible for making a network request to create/register a device. + * + * @param orgId orgId that was used for the the device creation/registration + * @param clientId clientId to be used for the the device creation/registration + * @param deviceName deviceName to be used for the the device creation/registration + * @param callback a callback that will be used to be notify of the response to the network request + */ +internal class QuickConnectDeviceCreator( + private val orgId: String, + private val clientId: String, + private val deviceName: String, + private val callback: AdobeCallback> +) : Runnable { + + companion object { + private const val LOG_SOURCE = "QuickConnectDeviceCreator" + } + + override fun run() { + val networkRequest: NetworkRequest? = try { + buildRequest() + } catch (e: Exception) { + Log.trace(Assurance.LOG_TAG, LOG_SOURCE, "Exception attempting to build request. ${e.message}") + null + } + + if (networkRequest == null) { + callback.call(Response.Failure(AssuranceConnectionError.CREATE_DEVICE_REQUEST_MALFORMED)) + return + } + + makeRequest(networkRequest) + } + + /** + * Builds the network request for creating device. + */ + private fun buildRequest(): NetworkRequest { + val url = + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_CREATE}" + + val headers: Map = mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ) + + val body: Map = mapOf( + QuickConnect.KEY_ORG_ID to orgId, + QuickConnect.KEY_DEVICE_NAME to deviceName, + QuickConnect.KEY_CLIENT_ID to clientId + ) + + val jsonBody = JSONObject(body) + + val bodyBytes: ByteArray = jsonBody.toString().toByteArray() + return NetworkRequest( + url, + HttpMethod.POST, + bodyBytes, + headers, + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + } + + /** + * Makes the network request to create device API. Uses [callback] to notify about the response. + */ + private fun makeRequest(networkRequest: NetworkRequest) { + ServiceProvider.getInstance().networkService.connectAsync(networkRequest) { response: HttpConnecting? -> + if (response == null) { + callback.call(Response.Failure(AssuranceConnectionError.UNEXPECTED_ERROR)) + return@connectAsync + } + + val responseCode = response.responseCode + if (responseCode == HttpsURLConnection.HTTP_CREATED || HttpsURLConnection.HTTP_OK == responseCode) { + Log.debug( + Assurance.LOG_TAG, + Assurance.LOG_TAG, + "Registration request succeeded: %s", + responseCode + ) + callback.call(Response.Success(response)) + } else { + Log.trace(Assurance.LOG_TAG, LOG_SOURCE, "Device registration failed with code : $responseCode and message: ${response.responseMessage}.") + callback.call( + Response.Failure(AssuranceConnectionError.CREATE_DEVICE_REQUEST_FAILED) + ) + } + + response.close() + } + } + + /** + * Exists to retrieve the [callback] reference for the sake of tests only. Used instead of the + * exposing the getter for callback in the constructor itself because the annotations are not retained with that way. + */ + @VisibleForTesting + fun getCallback(): AdobeCallback> { + return callback + } +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceStatusChecker.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceStatusChecker.kt new file mode 100644 index 0000000..2ad198f --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceStatusChecker.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import androidx.annotation.VisibleForTesting +import com.adobe.marketing.mobile.AdobeCallback +import com.adobe.marketing.mobile.Assurance.LOG_TAG +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceConstants.QuickConnect +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.NetworkingConstants +import com.adobe.marketing.mobile.services.ServiceProvider +import org.json.JSONObject +import javax.net.ssl.HttpsURLConnection + +/** + * Responsible for making a network request to check the status of the device creation (previously + * triggered via [QuickConnectDeviceCreator]) + * + * @param orgId orgId that was used for the the device creation/registration + * @param clientId clientId that was used for the the device creation/registration + * @param callback a callback to be notified of the response to the network request + */ +internal class QuickConnectDeviceStatusChecker( + private val orgId: String, + private val clientId: String, + private val callback: AdobeCallback> +) : Runnable { + + companion object { + private const val LOG_SOURCE = "QuickConnectDeviceStatusChecker" + } + + override fun run() { + val networkRequest = try { + buildRequest() + } catch (e: Exception) { + Log.trace(LOG_TAG, LOG_SOURCE, "Exception attempting to build request. ${e.message}") + null + } + + if (networkRequest == null) { + callback.call(Response.Failure(AssuranceConnectionError.STATUS_CHECK_REQUEST_MALFORMED)) + return + } + + makeRequest(networkRequest) + } + + /** + * Builds the network request for checking the status of device creation. + */ + private fun buildRequest(): NetworkRequest { + val url = "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_STATUS}" + + val body: Map = mapOf( + QuickConnect.KEY_ORG_ID to orgId, + QuickConnect.KEY_CLIENT_ID to clientId + ) + + val headers: Map = mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ) + val jsonBody = JSONObject(body) + val bodyBytes = jsonBody.toString().toByteArray() + return NetworkRequest( + url, + HttpMethod.POST, + bodyBytes, + headers, + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + } + + /** + * Makes the network request to check the status of device creation. Uses [callback] to notify about the response. + */ + private fun makeRequest(networkRequest: NetworkRequest) { + ServiceProvider.getInstance().networkService.connectAsync(networkRequest) { response: HttpConnecting? -> + if (response == null) { + callback.call(Response.Failure(AssuranceConnectionError.UNEXPECTED_ERROR)) + return@connectAsync + } + + val responseCode = response.responseCode + if (!(responseCode == HttpsURLConnection.HTTP_CREATED || responseCode == HttpsURLConnection.HTTP_OK)) { + Log.trace(LOG_TAG, LOG_SOURCE, "Device status check failed with code : $responseCode and message: ${response.responseMessage}.") + callback.call(Response.Failure(AssuranceConnectionError.DEVICE_STATUS_REQUEST_FAILED)) + } else { + callback.call(Response.Success(response)) + } + + response.close() + } + } + + /** + * Exists to retrieve the [callback] reference for the sake of tests only. Used instead of the + * exposing the getter for callback in the constructor itself because the annotations are not retained with that way. + */ + @VisibleForTesting + internal fun getCallback(): AdobeCallback> { + return callback + } +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectManager.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectManager.kt new file mode 100644 index 0000000..922aaad --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/QuickConnectManager.kt @@ -0,0 +1,220 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import androidx.annotation.VisibleForTesting +import com.adobe.marketing.mobile.Assurance.LOG_TAG +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceConstants.QuickConnect +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.Log +import com.adobe.marketing.mobile.services.ServiceProvider +import com.adobe.marketing.mobile.util.StreamUtils +import com.adobe.marketing.mobile.util.StringUtils +import org.json.JSONException +import org.json.JSONObject +import org.json.JSONTokener +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +/** + * Responsible for manging the workflow that registers the device as one capable of initiating a QuickConnect session. + * A typical flow includes device creation, status checks with retries, success / failure notifications. + */ +internal class QuickConnectManager( + private val assuranceSharedStateManager: AssuranceStateManager, + private val executorService: ScheduledExecutorService, + private val quickConnectCallback: QuickConnectCallback +) { + + companion object { + private const val LOG_SOURCE = "QuickConnectManager" + } + + /** + * A data class to hold the details of the response from the create device API. + */ + internal data class QuickConnectSessionDetails(val sessionId: String, val token: String) + + /** + * Represents the number of retries made for a status check. + */ + @Volatile + private var retryCount = 0 + + /** + * Represents if there is an active attempt to initiate a QuickConnect session. + */ + @VisibleForTesting + @Volatile + internal var isActive = false + private set + + /** + * A handle to the device creation task. + */ + @VisibleForTesting + internal var deviceCreationTaskHandle: Future<*>? = null + private set + + /** + * A handle to the device status check task. + */ + @VisibleForTesting + internal var deviceStatusTaskHandle: ScheduledFuture<*>? = null + private set + + /** + * Initiates device registration by triggering the [QuickConnectDeviceCreator] + */ + internal fun registerDevice() { + if (isActive) { + return + } + + isActive = true + + val orgId = assuranceSharedStateManager.getOrgId(false) + val clientId = assuranceSharedStateManager.clientId + val deviceName = ServiceProvider.getInstance().deviceInfoService.deviceName + Log.trace(LOG_TAG, LOG_SOURCE, "Attempting to register device with deviceName:$deviceName, orgId: $orgId, clientId: $clientId.") + + val quickConnectDeviceCreator = QuickConnectDeviceCreator(orgId, clientId, deviceName) { + when (it) { + is Response.Success -> checkDeviceStatus(orgId, clientId) + is Response.Failure -> { + quickConnectCallback.onError(it.error) + cleanup() + } + } + } + + deviceCreationTaskHandle = executorService.submit(quickConnectDeviceCreator) + } + + /** + * Periodically checks the status of quick connect device registration that was triggered by [registerDevice] + * + * @param orgId the orgId for which quick connect was initiated + * @param clientId the clientId for which quick connect was initiated + */ + @VisibleForTesting + internal fun checkDeviceStatus(orgId: String, clientId: String) { + val statusCheckerTask = QuickConnectDeviceStatusChecker(orgId, clientId) { response -> + handleStatusCheckResponse(orgId, clientId, response) + } + + deviceStatusTaskHandle = executorService.schedule(statusCheckerTask, QuickConnect.STATUS_CHECK_DELAY_MS, TimeUnit.MILLISECONDS) + } + + /** + * Cancels an ongoing quick connect device registration workflow (if any). + */ + internal fun cancel() { + cleanup() + } + + /** + * Handles the response from the device status check. Conditionally triggers a new status check + * if the request was successful without session details. + * + * @param orgId the orgId for which quick connect was initiated + * @param clientId the clientId for which quick connect was initiated + * @param response the [Response] from the [checkDeviceStatus] request that is to be handled + */ + private fun handleStatusCheckResponse(orgId: String, clientId: String, response: Response) { + when (response) { + is Response.Success -> { + val sessionDetails = extractSessionDetails(StreamUtils.readAsString(response.data.inputStream)) + if (sessionDetails != null) { + // quick connect session details are available. Notify about successful the result. + Log.trace(LOG_TAG, LOG_SOURCE, "Received session details.") + + quickConnectCallback.onSuccess(sessionDetails.sessionId, sessionDetails.token) + cleanup() + return + } + + // The request was successful but the session data is not yet present. + + if (!isActive) { + // The workflow is likely cancelled due to user interaction. Do not retry. + Log.trace(LOG_TAG, LOG_SOURCE, "Will not retry. QuickConnect workflow already cancelled.") + return + } + + if (++retryCount < QuickConnect.MAX_RETRY_COUNT) { + Log.trace(LOG_TAG, LOG_SOURCE, "Will retry device status check.") + checkDeviceStatus(orgId, clientId) + } else { + // Maximum allowed retries for checking the status has been reached. + Log.trace(LOG_TAG, LOG_SOURCE, "Will not retry. Maximum allowed retries for status check have been reached.") + quickConnectCallback.onError(AssuranceConnectionError.RETRY_LIMIT_REACHED) + cleanup() + } + } + + is Response.Failure -> { + Log.trace(LOG_TAG, LOG_SOURCE, "Device status check request failed.") + quickConnectCallback.onError(response.error) + cleanup() + } + } + } + + /** + * Extracts quick connect session details from the provided [jsonString]. + * + * @return valid [QuickConnectSessionDetails] when successfully parsed; + * null if json string is empty, or details are unavailable. + */ + private fun extractSessionDetails(jsonString: String?): QuickConnectSessionDetails? { + if (jsonString.isNullOrEmpty()) return null + + return try { + val jsonObject = JSONObject(JSONTokener(jsonString)) + val sessionUUID = jsonObject.optString(QuickConnect.KEY_SESSION_ID) + val token = jsonObject.optString(QuickConnect.KEY_SESSION_TOKEN) + if (StringUtils.isNullOrEmpty(sessionUUID) || + StringUtils.isNullOrEmpty(token) || + "null".equals(sessionUUID, true) || + "null".equals(token, true) + ) { + null + } else { + QuickConnectSessionDetails(sessionUUID, token) + } + } catch (e: JSONException) { + null + } + } + + /** + * Terminates any pending tasks and resets the state of this class. + */ + private fun cleanup() { + deviceCreationTaskHandle?.let { + it.cancel(true) + Log.trace(LOG_TAG, LOG_SOURCE, "QuickConnect device creation task cancelled") + }.also { deviceCreationTaskHandle = null } + + deviceStatusTaskHandle?.let { + it.cancel(true) + Log.debug(LOG_TAG, LOG_SOURCE, "QuickConnect device status task cancelled") + }.also { deviceStatusTaskHandle = null } + + retryCount = 0 + isActive = false + } +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/Response.kt b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/Response.kt new file mode 100644 index 0000000..578a616 --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/Response.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +/** + * Represents a result of an action/operation. + * Exists primarily to provide the ability to use its subclasses exhaustively in expressions like + * if, when, apply etc allowing cleaner and defined response handling. + */ +internal sealed class Response { + class Success(val data: T) : Response() + class Failure(val error: V) : Response() +} diff --git a/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/SessionAuthorizingPresentation.java b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/SessionAuthorizingPresentation.java new file mode 100644 index 0000000..837e662 --- /dev/null +++ b/code/assurance/src/main/java/com/adobe/marketing/mobile/assurance/SessionAuthorizingPresentation.java @@ -0,0 +1,56 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance; + +/** Represents a UI that authorizes an Assurance session connection. */ +interface SessionAuthorizingPresentation { + + enum Type { + PIN, + QUICK_CONNECT + } + + /** + * Checks if this authorization UI is currently being displayed. + * + * @return true if the authorization UI is visible; false otherwise. + */ + boolean isDisplayed(); + + /** Shows the authorization UI. */ + void showAuthorization(); + + /** + * Resurfaces the authorization UI if it is not already visible (needed to deal with + * simultaneous activity launch edge cases) + */ + void reorderToFront(); + + /** Changes the authorization UI to indicate that the associated session is connecting. */ + void onConnecting(); + + /** + * Changes the authorization UI to indicate that the associated session is successfully + * connected. + */ + void onConnectionSucceeded(); + + /** + * Changes the authorization UI to indicate that the associated session has failed to connect. + * + * @param connectionError the {@code AssuranceConnectionError} that resulted during connection + * @param shouldShowRetry whether the UI should allow a retry + */ + void onConnectionFailed( + final AssuranceConstants.AssuranceConnectionError connectionError, + final boolean shouldShowRetry); +} diff --git a/code/assurance/src/main/res/drawable/img_adobelogo.png b/code/assurance/src/main/res/drawable/img_adobelogo.png new file mode 100644 index 0000000..88e9842 Binary files /dev/null and b/code/assurance/src/main/res/drawable/img_adobelogo.png differ diff --git a/code/assurance/src/main/res/drawable/img_quick_connect.png b/code/assurance/src/main/res/drawable/img_quick_connect.png new file mode 100644 index 0000000..7203fa7 Binary files /dev/null and b/code/assurance/src/main/res/drawable/img_quick_connect.png differ diff --git a/code/assurance/src/main/res/values/colors.xml b/code/assurance/src/main/res/drawable/shape_custom_button_filled.xml similarity index 60% rename from code/assurance/src/main/res/values/colors.xml rename to code/assurance/src/main/res/drawable/shape_custom_button_filled.xml index bd7d9f2..ce7f91e 100644 --- a/code/assurance/src/main/res/values/colors.xml +++ b/code/assurance/src/main/res/drawable/shape_custom_button_filled.xml @@ -1,6 +1,5 @@ - - - - #008577 - #00574B - #D81B60 - + + + + + \ No newline at end of file diff --git a/code/assurance/src/main/res/values/styles.xml b/code/assurance/src/main/res/drawable/shape_custom_button_inactive.xml similarity index 56% rename from code/assurance/src/main/res/values/styles.xml rename to code/assurance/src/main/res/drawable/shape_custom_button_inactive.xml index 3a23cb9..ce0f274 100644 --- a/code/assurance/src/main/res/values/styles.xml +++ b/code/assurance/src/main/res/drawable/shape_custom_button_inactive.xml @@ -1,5 +1,5 @@ - - - - - - + + + + + \ No newline at end of file diff --git a/code/assurance/src/main/res/drawable/shape_custom_button_outlined.xml b/code/assurance/src/main/res/drawable/shape_custom_button_outlined.xml new file mode 100644 index 0000000..4d27bd9 --- /dev/null +++ b/code/assurance/src/main/res/drawable/shape_custom_button_outlined.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/code/assurance/src/main/res/layout/progress_button_layout.xml b/code/assurance/src/main/res/layout/progress_button_layout.xml new file mode 100644 index 0000000..fea341c --- /dev/null +++ b/code/assurance/src/main/res/layout/progress_button_layout.xml @@ -0,0 +1,47 @@ + + + + + + + + + \ No newline at end of file diff --git a/code/assurance/src/main/res/layout/quick_connect_screen_layout.xml b/code/assurance/src/main/res/layout/quick_connect_screen_layout.xml new file mode 100644 index 0000000..88fbbed --- /dev/null +++ b/code/assurance/src/main/res/layout/quick_connect_screen_layout.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/code/assurance/src/main/res/values/strings.xml b/code/assurance/src/main/res/values/strings.xml index 4871a35..60e3209 100644 --- a/code/assurance/src/main/res/values/strings.xml +++ b/code/assurance/src/main/res/values/strings.xml @@ -11,4 +11,11 @@ Assurance + Assurance + Confirm connection by visiting your session\'s connection detail screen + Connect + Cancel + Waiting.. + Retry + diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceExtensionTest.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceExtensionTest.java index 44a1851..db8b8e3 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceExtensionTest.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceExtensionTest.java @@ -11,21 +11,29 @@ package com.adobe.marketing.mobile.assurance; +import static com.adobe.marketing.mobile.assurance.AssuranceConstants.SDKEventDataKey.IS_QUICK_CONNECT; +import static com.adobe.marketing.mobile.assurance.AssuranceConstants.SDKEventDataKey.START_SESSION_URL; import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertNotNull; import static junit.framework.TestCase.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.app.Activity; import android.app.Application; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.ApplicationInfo; import android.net.Uri; import com.adobe.marketing.mobile.Assurance; import com.adobe.marketing.mobile.Event; @@ -36,18 +44,27 @@ import com.adobe.marketing.mobile.SharedStateResolution; import com.adobe.marketing.mobile.SharedStateResult; import com.adobe.marketing.mobile.SharedStateStatus; +import com.adobe.marketing.mobile.services.AppContextService; +import com.adobe.marketing.mobile.services.ServiceProvider; +import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.TimeUnit; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) public class AssuranceExtensionTest { private static final String START_URL_QUERY_KEY_SESSION_ID = "adb_validation_sessionid"; @@ -73,8 +90,13 @@ public class AssuranceExtensionTest { @Mock AssuranceSessionOrchestrator mockAssuranceSessionOrchestrator; + @Mock ServiceProvider mockServiceProvider; + + @Mock AppContextService mockAppContextService; + private MockedStatic mockedStaticUri; private MockedStatic mockedStaticMobileCore; + private MockedStatic mockedStaticServiceProvider; AssuranceExtension assuranceExtension; @@ -103,6 +125,12 @@ public void setup() { .thenReturn(mockSharedPreference); Mockito.when(mockSharedPreference.edit()).thenReturn(mockSharedPreferenceEditor); + mockedStaticServiceProvider = Mockito.mockStatic(ServiceProvider.class); + mockedStaticServiceProvider + .when(ServiceProvider::getInstance) + .thenReturn(mockServiceProvider); + when(mockServiceProvider.getAppContextService()).thenReturn(mockAppContextService); + assuranceExtension = new AssuranceExtension( mockApi, @@ -117,7 +145,7 @@ public void test_AssuranceExtensionShutsDownAfter5Seconds() throws Exception { // wait for 5 seconds Thread.sleep(TimeUnit.SECONDS.toMillis(6L)); - verify(mockAssuranceSessionOrchestrator, times(1)).terminateSession(); + verify(mockAssuranceSessionOrchestrator, times(1)).terminateSession(true); } @Test @@ -131,12 +159,14 @@ public void test_AssuranceDoesNotShutDown_When_StartSessionAPICalled() throws Ex Thread.sleep(TimeUnit.SECONDS.toMillis(6L)); // verify that the extension is still running - verify(mockAssuranceSessionOrchestrator, never()).terminateSession(); + verify(mockAssuranceSessionOrchestrator, never()).terminateSession(anyBoolean()); verify(mockAssuranceSessionOrchestrator, times(1)) .createSession( "6b55294e-32d4-49e8-9279-e3fe12a9d309", AssuranceConstants.AssuranceEnvironment.PROD, - null); + null, + null, + SessionAuthorizingPresentation.Type.PIN); } @Test @@ -151,12 +181,14 @@ public void test_StartSession_InvalidSessionID() throws Exception { assuranceExtension.startSession(DEEPLINK1); assuranceExtension.startSession(DEEPLINK2); - // verify that shared state is not created + // verify that session is not created verify(mockAssuranceSessionOrchestrator, times(0)) .createSession( anyString(), any(AssuranceConstants.AssuranceEnvironment.class), - anyString()); + anyString(), + any(AssuranceSession.AssuranceSessionStatusListener.class), + any(SessionAuthorizingPresentation.Type.class)); } @Test @@ -168,12 +200,14 @@ public void test_StartSession_SessionAlreadyExists() throws Exception { // test assuranceExtension.startSession(DEEPLINK); - // verify that shared state is not created + // verify that session is not created verify(mockAssuranceSessionOrchestrator, times(0)) .createSession( anyString(), any(AssuranceConstants.AssuranceEnvironment.class), - anyString()); + anyString(), + any(AssuranceSession.AssuranceSessionStatusListener.class), + any(SessionAuthorizingPresentation.Type.class)); } @Test @@ -185,6 +219,228 @@ public void testStartSessionBogusUrl() { } } + @Test + public void test_startSession_quickConnect_hostAppNotInDebugBuild() { + final Context mockAppContext = mock(Context.class); + when(mockAppContextService.getApplication()).thenReturn(mockApplication); + when(mockApplication.getApplicationContext()).thenReturn(mockAppContext); + final ApplicationInfo mockApplicationInfo = Mockito.mock(ApplicationInfo.class); + // Set application flag to be non-debuggable + mockApplicationInfo.flags = + ((mockApplicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) == 0) + ? mockApplicationInfo.flags + : (mockApplicationInfo.flags | ApplicationInfo.FLAG_DEBUGGABLE); + when(mockAppContext.getApplicationInfo()).thenReturn(mockApplicationInfo); + + final Activity mockActivity = Mockito.mock(Activity.class); + when(mockAppContextService.getCurrentActivity()).thenReturn(mockActivity); + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + assuranceExtension.startSession(); + + verify(mockActivity, times(0)).startActivity(any(Intent.class)); + } + + @Test + public void test_startSession_quickConnect_hostAppInDebugBuild() { + final Context mockAppContext = mock(Context.class); + when(mockAppContextService.getApplication()).thenReturn(mockApplication); + when(mockApplication.getApplicationContext()).thenReturn(mockAppContext); + final ApplicationInfo mockApplicationInfo = Mockito.mock(ApplicationInfo.class); + // Set application flag to debuggable + mockApplicationInfo.flags = + ((mockApplicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) + ? mockApplicationInfo.flags + : (mockApplicationInfo.flags | ApplicationInfo.FLAG_DEBUGGABLE); + when(mockAppContext.getApplicationInfo()).thenReturn(mockApplicationInfo); + + final Activity mockActivity = Mockito.mock(Activity.class); + when(mockAppContextService.getCurrentActivity()).thenReturn(mockActivity); + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + assuranceExtension.startSession(); + + final ArgumentCaptor quickConnectIntentCaptor = + ArgumentCaptor.forClass(Intent.class); + verify(mockActivity).startActivity(quickConnectIntentCaptor.capture()); + final Intent capturedIntent = quickConnectIntentCaptor.getValue(); + assertEquals( + AssuranceQuickConnectActivity.class.getName(), + capturedIntent.getComponent().getClassName()); + } + + @Test + public void test_startSession_quickConnect_activeSessionExists() { + final Context mockAppContext = mock(Context.class); + when(mockAppContextService.getApplication()).thenReturn(mockApplication); + when(mockApplication.getApplicationContext()).thenReturn(mockAppContext); + final ApplicationInfo mockApplicationInfo = Mockito.mock(ApplicationInfo.class); + // Set application flag to be debuggable + mockApplicationInfo.flags = + ((mockApplicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) + ? mockApplicationInfo.flags + : (mockApplicationInfo.flags | ApplicationInfo.FLAG_DEBUGGABLE); + when(mockAppContext.getApplicationInfo()).thenReturn(mockApplicationInfo); + + final Activity mockActivity = Mockito.mock(Activity.class); + when(mockAppContextService.getCurrentActivity()).thenReturn(mockActivity); + when(mockAssuranceSessionOrchestrator.getActiveSession()) + .thenReturn(mock(AssuranceSession.class)); + + assuranceExtension.startSession(); + + verify(mockActivity, times(0)).startActivity(any(Intent.class)); + } + + @Test + public void test_startSession_quickConnect_hostApplicationIsNull() { + final Activity mockActivity = Mockito.mock(Activity.class); + when(mockAppContextService.getCurrentActivity()).thenReturn(mockActivity); + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + when(mockAppContextService.getApplication()).thenReturn(null); + + assuranceExtension.startSession(); + + verify(mockActivity, times(0)).startActivity(any(Intent.class)); + } + + @Test + public void test_AssuranceDoesNotShutDown_When_QuickConnectStartSessionAPICalled() + throws Exception { + // simulate no active session + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + assuranceExtension.startSession(); + + Thread.sleep(TimeUnit.SECONDS.toMillis(6L)); + + // verify that the extension is still running + verify(mockAssuranceSessionOrchestrator, never()).terminateSession(anyBoolean()); + } + + @Test + public void test_handleAssuranceRequestContent_eventDataContainsDeeplink() { + // simulate no active session + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + // simulate deeplink based start + final Map startSessionEventData = new HashMap<>(); + final String sessionId = UUID.randomUUID().toString(); + startSessionEventData.put( + START_SESSION_URL, "aepsdkassurance://?adb_validation_sessionid=" + sessionId); + when(mockUri.getQueryParameter(START_URL_QUERY_KEY_SESSION_ID)).thenReturn((sessionId)); + final Event startSessionEvent = + new Event.Builder( + "Assurance Start Session", + EventType.ASSURANCE, + EventSource.REQUEST_CONTENT) + .setEventData(startSessionEventData) + .build(); + + // test + assuranceExtension.handleAssuranceRequestContent(startSessionEvent); + + // verify that a PIN authorized session is created + verify(mockAssuranceSessionOrchestrator) + .createSession( + sessionId, + AssuranceConstants.AssuranceEnvironment.PROD, + null, + null, + SessionAuthorizingPresentation.Type.PIN); + } + + @Test + public void test_handleAssuranceRequestContent_eventDataContainsQuickConnectFlag() { + final Context mockAppContext = mock(Context.class); + when(mockAppContextService.getApplication()).thenReturn(mockApplication); + when(mockApplication.getApplicationContext()).thenReturn(mockAppContext); + final ApplicationInfo mockApplicationInfo = Mockito.mock(ApplicationInfo.class); + + // set application flag to debuggable + mockApplicationInfo.flags = + ((mockApplicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) + ? mockApplicationInfo.flags + : (mockApplicationInfo.flags | ApplicationInfo.FLAG_DEBUGGABLE); + when(mockAppContext.getApplicationInfo()).thenReturn(mockApplicationInfo); + + // simulate foreground activity + final Activity mockActivity = Mockito.mock(Activity.class); + when(mockAppContextService.getCurrentActivity()).thenReturn(mockActivity); + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + // simulate no active session + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + // simulate quick connect based start + final Event startSessionEvent = + new Event.Builder( + "Assurance Start Session (Quick Connect)", + EventType.ASSURANCE, + EventSource.REQUEST_CONTENT) + .setEventData(Collections.singletonMap(IS_QUICK_CONNECT, true)) + .build(); + + // Test + assuranceExtension.handleAssuranceRequestContent(startSessionEvent); + + // verify that no PIN based connection is triggered + verify(mockAssuranceSessionOrchestrator, never()) + .createSession( + any(), any(), any(), any(), eq(SessionAuthorizingPresentation.Type.PIN)); + + // verify that quick connect flow is launched + final ArgumentCaptor quickConnectIntentCaptor = + ArgumentCaptor.forClass(Intent.class); + verify(mockActivity).startActivity(quickConnectIntentCaptor.capture()); + final Intent capturedIntent = quickConnectIntentCaptor.getValue(); + assertEquals( + AssuranceQuickConnectActivity.class.getName(), + capturedIntent.getComponent().getClassName()); + } + + @Test + public void test_handleAssuranceRequestContent_eventDataIsInvalid() { + final Context mockAppContext = mock(Context.class); + when(mockAppContextService.getApplication()).thenReturn(mockApplication); + when(mockApplication.getApplicationContext()).thenReturn(mockAppContext); + final ApplicationInfo mockApplicationInfo = Mockito.mock(ApplicationInfo.class); + + // set application flag to debuggable + mockApplicationInfo.flags = + ((mockApplicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) + ? mockApplicationInfo.flags + : (mockApplicationInfo.flags | ApplicationInfo.FLAG_DEBUGGABLE); + when(mockAppContext.getApplicationInfo()).thenReturn(mockApplicationInfo); + + // simulate foreground activity + final Activity mockActivity = Mockito.mock(Activity.class); + when(mockAppContextService.getCurrentActivity()).thenReturn(mockActivity); + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + // simulate no active session + when(mockAssuranceSessionOrchestrator.getActiveSession()).thenReturn(null); + + // simulate start event with invalid event data + final Event startSessionEvent = + new Event.Builder( + "Assurance Start Session", + EventType.ASSURANCE, + EventSource.REQUEST_CONTENT) + .setEventData(Collections.singletonMap("someRandomKey", "someRandomValue")) + .build(); + + // test + assuranceExtension.handleAssuranceRequestContent(startSessionEvent); + + // verify that no session is created + verify(mockAssuranceSessionOrchestrator, times(0)) + .createSession(any(), any(), any(), any(), any()); + + // verify that no activity is launched + verify(mockActivity, times(0)).startActivity(any(Intent.class)); + } + @Test public void testProcessWildcardEvent() { // setup @@ -444,5 +700,6 @@ public void test_OnRegistered_WhenSessionNotAvailable() { public void teardown() { mockedStaticMobileCore.close(); mockedStaticUri.close(); + mockedStaticServiceProvider.close(); } } diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestratorTest.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestratorTest.java index 88fe76e..c9fe3a5 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestratorTest.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionOrchestratorTest.java @@ -12,8 +12,12 @@ package com.adobe.marketing.mobile.assurance; import static com.adobe.marketing.mobile.assurance.AssuranceTestUtils.setInternalState; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,6 +49,9 @@ public class AssuranceSessionOrchestratorTest { @Mock private AssuranceSessionOrchestrator.AssuranceSessionCreator mockAssuranceSessionCreator; @Mock private AssuranceSession mockAssuranceSession; + @Mock + private AssuranceSession.AssuranceSessionStatusListener mockAuthorizingPresentationListener; + private AssuranceSessionOrchestrator.HostAppActivityLifecycleObserver hostAppActivityLifecycleObserver; @@ -75,7 +82,7 @@ public void setUp() throws Exception { } @Test - public void testCreateSession_WithoutPin() { + public void testCreateSession_WithoutPin_PinBasedAuthorization() { when(mockAssuranceSessionCreator.create( eq("SessionID"), eq(AssuranceConstants.AssuranceEnvironment.PROD), @@ -84,12 +91,18 @@ public void testCreateSession_WithoutPin() { eq(mockPluginList), eq(mockAssuranceConnectionDataStore), eq(mockApplicationHandle), - ArgumentMatchers.>any())) + ArgumentMatchers.>any(), + any(), + eq(SessionAuthorizingPresentation.Type.PIN))) .thenReturn(mockAssuranceSession); Assert.assertNull(assuranceSessionOrchestrator.getActiveSession()); assuranceSessionOrchestrator.createSession( - "SessionID", AssuranceConstants.AssuranceEnvironment.PROD, null); + "SessionID", + AssuranceConstants.AssuranceEnvironment.PROD, + null, + null, + SessionAuthorizingPresentation.Type.PIN); Assert.assertNotNull(assuranceSessionOrchestrator.getActiveSession()); @@ -101,7 +114,7 @@ public void testCreateSession_WithoutPin() { } @Test - public void testCreateSession_WithPin() { + public void testCreateSession_WithPin_PinBasedAuthorization() { when(mockAssuranceSessionCreator.create( eq("SessionID"), eq(AssuranceConstants.AssuranceEnvironment.PROD), @@ -110,12 +123,18 @@ public void testCreateSession_WithPin() { eq(mockPluginList), eq(mockAssuranceConnectionDataStore), eq(mockApplicationHandle), - ArgumentMatchers.>any())) + ArgumentMatchers.>any(), + any(), + eq(SessionAuthorizingPresentation.Type.PIN))) .thenReturn(mockAssuranceSession); Assert.assertNull(assuranceSessionOrchestrator.getActiveSession()); assuranceSessionOrchestrator.createSession( - "SessionID", AssuranceConstants.AssuranceEnvironment.PROD, "1234"); + "SessionID", + AssuranceConstants.AssuranceEnvironment.PROD, + "1234", + null, + SessionAuthorizingPresentation.Type.PIN); Assert.assertNotNull(assuranceSessionOrchestrator.getActiveSession()); @@ -127,7 +146,38 @@ public void testCreateSession_WithPin() { } @Test - public void testTerminateSession() { + public void testCreateSession_WithPin_QuickConnectAuthorization() { + when(mockAssuranceSessionCreator.create( + eq("SessionID"), + eq(AssuranceConstants.AssuranceEnvironment.PROD), + eq(assuranceSessionOrchestrator.getSessionUIOperationHandler()), + eq(mockAssuranceStateManager), + eq(mockPluginList), + eq(mockAssuranceConnectionDataStore), + eq(mockApplicationHandle), + ArgumentMatchers.>any(), + eq(mockAuthorizingPresentationListener), + eq(SessionAuthorizingPresentation.Type.QUICK_CONNECT))) + .thenReturn(mockAssuranceSession); + Assert.assertNull(assuranceSessionOrchestrator.getActiveSession()); + + assuranceSessionOrchestrator.createSession( + "SessionID", + AssuranceConstants.AssuranceEnvironment.PROD, + "1234", + mockAuthorizingPresentationListener, + SessionAuthorizingPresentation.Type.QUICK_CONNECT); + + Assert.assertNotNull(assuranceSessionOrchestrator.getActiveSession()); + verify(mockAssuranceSession) + .registerStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceStateManager).shareAssuranceSharedState("SessionID"); + verify(mockAssuranceSession).connect("1234"); + } + + @Test + public void testTerminateSession_PinBasedSession() { // prepare when(mockAssuranceSessionCreator.create( eq("SessionID"), @@ -137,14 +187,54 @@ public void testTerminateSession() { eq(mockPluginList), eq(mockAssuranceConnectionDataStore), eq(mockApplicationHandle), - ArgumentMatchers.>any())) + ArgumentMatchers.>any(), + any(), + eq(SessionAuthorizingPresentation.Type.PIN))) .thenReturn(mockAssuranceSession); assuranceSessionOrchestrator.createSession( - "SessionID", AssuranceConstants.AssuranceEnvironment.PROD, "1234"); + "SessionID", + AssuranceConstants.AssuranceEnvironment.PROD, + "1234", + null, + SessionAuthorizingPresentation.Type.PIN); // test - assuranceSessionOrchestrator.terminateSession(); + assuranceSessionOrchestrator.terminateSession(true); + + verify(mockAssuranceStateManager).clearAssuranceSharedState(); + verify(mockAssuranceSession) + .unregisterStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceSession).disconnect(); + Assert.assertNull(assuranceSessionOrchestrator.getActiveSession()); + } + + @Test + public void testTerminateSession_QuickConnectSession() { + // prepare + when(mockAssuranceSessionCreator.create( + eq("SessionID"), + eq(AssuranceConstants.AssuranceEnvironment.PROD), + eq(assuranceSessionOrchestrator.getSessionUIOperationHandler()), + eq(mockAssuranceStateManager), + eq(mockPluginList), + eq(mockAssuranceConnectionDataStore), + eq(mockApplicationHandle), + ArgumentMatchers.>any(), + eq(mockAuthorizingPresentationListener), + eq(SessionAuthorizingPresentation.Type.QUICK_CONNECT))) + .thenReturn(mockAssuranceSession); + + assuranceSessionOrchestrator.createSession( + "SessionID", + AssuranceConstants.AssuranceEnvironment.PROD, + "1234", + mockAuthorizingPresentationListener, + SessionAuthorizingPresentation.Type.QUICK_CONNECT); + + // test + assuranceSessionOrchestrator.terminateSession(true); verify(mockAssuranceStateManager).clearAssuranceSharedState(); verify(mockAssuranceSession) @@ -190,11 +280,13 @@ public void testReconnectToStoredSession_HasStoredSessionURL() throws Exception eq(mockPluginList), eq(mockAssuranceConnectionDataStore), eq(mockApplicationHandle), - ArgumentMatchers.>any())) + ArgumentMatchers.>any(), + any(), + any(SessionAuthorizingPresentation.Type.class))) .thenReturn(mockAssuranceSession); when(mockAssuranceConnectionDataStore.getStoredConnectionURL()).thenReturn(connectionUrl); - Assert.assertTrue(assuranceSessionOrchestrator.reconnectToStoredSession()); + assertTrue(assuranceSessionOrchestrator.reconnectToStoredSession()); verify(mockAssuranceSessionCreator) .create( eq("5ccd5a20-1c00-4d6e-bf77-bbe85bc0c758"), @@ -204,7 +296,9 @@ public void testReconnectToStoredSession_HasStoredSessionURL() throws Exception eq(mockPluginList), eq(mockAssuranceConnectionDataStore), eq(mockApplicationHandle), - ArgumentMatchers.>any()); + ArgumentMatchers.>any(), + any(), + any(SessionAuthorizingPresentation.Type.class)); } @Test @@ -239,15 +333,15 @@ public void testCanProcessSDKEvents() { final List mockBuffer = Mockito.mock(List.class); setInternalState(assuranceSessionOrchestrator, "outboundEventBuffer", mockBuffer); - Assert.assertTrue(assuranceSessionOrchestrator.canProcessSDKEvents()); + assertTrue(assuranceSessionOrchestrator.canProcessSDKEvents()); setInternalState(assuranceSessionOrchestrator, "outboundEventBuffer", (Object[]) null); setInternalState(assuranceSessionOrchestrator, "session", mockAssuranceSession); - Assert.assertTrue(assuranceSessionOrchestrator.canProcessSDKEvents()); + assertTrue(assuranceSessionOrchestrator.canProcessSDKEvents()); setInternalState(assuranceSessionOrchestrator, "outboundEventBuffer", mockBuffer); setInternalState(assuranceSessionOrchestrator, "session", mockAssuranceSession); - Assert.assertTrue(assuranceSessionOrchestrator.canProcessSDKEvents()); + assertTrue(assuranceSessionOrchestrator.canProcessSDKEvents()); } @Test @@ -263,8 +357,30 @@ public void testSessionUIOperationHandler_OnConnect() { verify(mockAssuranceSession).connect("1234"); } + @Test + public void testSessionUIOperationHandler_OnConnect_EmptyPin() { + final List mockBuffer = Mockito.mock(List.class); + setInternalState(assuranceSessionOrchestrator, "outboundEventBuffer", mockBuffer); + setInternalState(assuranceSessionOrchestrator, "session", mockAssuranceSession); + + AssuranceSessionOrchestrator.SessionUIOperationHandler sessionUIOperationHandler = + assuranceSessionOrchestrator.getSessionUIOperationHandler(); + Assert.assertNotNull(sessionUIOperationHandler); + + sessionUIOperationHandler.onConnect(""); + + verify(mockAssuranceSession).disconnect(); + verify(mockAssuranceSession) + .unregisterStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceStateManager).clearAssuranceSharedState(); + verify(mockBuffer).clear(); + } + @Test public void testSessionUIOperationHandler_OnDisconnect() { + final List mockBuffer = Mockito.mock(List.class); + setInternalState(assuranceSessionOrchestrator, "outboundEventBuffer", mockBuffer); setInternalState(assuranceSessionOrchestrator, "session", mockAssuranceSession); AssuranceSessionOrchestrator.SessionUIOperationHandler sessionUIOperationHandler = assuranceSessionOrchestrator.getSessionUIOperationHandler(); @@ -276,6 +392,96 @@ public void testSessionUIOperationHandler_OnDisconnect() { .unregisterStatusListener( assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); verify(mockAssuranceSession).disconnect(); + verify(mockAssuranceSession) + .unregisterStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceStateManager).clearAssuranceSharedState(); + verify(mockBuffer).clear(); Assert.assertNull(assuranceSessionOrchestrator.getActiveSession()); } + + @Test + public void testSessionUIOperationHandler_OnQuickConnect_NoExistingSession() { + // create a mock session only if the parameters match valid quick connect trigger + when(mockAssuranceSessionCreator.create( + eq("SessionID"), + eq(AssuranceConstants.AssuranceEnvironment.PROD), + eq(assuranceSessionOrchestrator.getSessionUIOperationHandler()), + eq(mockAssuranceStateManager), + eq(mockPluginList), + eq(mockAssuranceConnectionDataStore), + eq(mockApplicationHandle), + ArgumentMatchers.>any(), + eq(mockAuthorizingPresentationListener), + eq(SessionAuthorizingPresentation.Type.QUICK_CONNECT))) + .thenReturn(mockAssuranceSession); + + AssuranceSessionOrchestrator.SessionUIOperationHandler sessionUIOperationHandler = + assuranceSessionOrchestrator.getSessionUIOperationHandler(); + Assert.assertNotNull(sessionUIOperationHandler); + + sessionUIOperationHandler.onQuickConnect( + "SessionID", "1234", mockAuthorizingPresentationListener); + + // Verify that a session is created (with the exact parameters expected by + // mockAssuranceSessionCreator) + assertNotNull(assuranceSessionOrchestrator.getActiveSession()); + verify(mockAssuranceSession).connect("1234"); + } + + @Test + public void testSessionUIOperationHandler_OnQuickConnect_ExistingQuickConnectSession() { + // setup mockAssuranceSessionCreator to create a new QuikConnect session + final AssuranceSession newMockSession = Mockito.mock(AssuranceSession.class); + when(mockAssuranceSessionCreator.create( + eq("SessionID"), + eq(AssuranceConstants.AssuranceEnvironment.PROD), + eq(assuranceSessionOrchestrator.getSessionUIOperationHandler()), + eq(mockAssuranceStateManager), + eq(mockPluginList), + eq(mockAssuranceConnectionDataStore), + eq(mockApplicationHandle), + ArgumentMatchers.>any(), + eq(mockAuthorizingPresentationListener), + eq(SessionAuthorizingPresentation.Type.QUICK_CONNECT))) + .thenReturn(newMockSession); + + // simulate existing quick connect session and a queued buffer + setInternalState(assuranceSessionOrchestrator, "session", mockAssuranceSession); + when(mockAssuranceSession.getAuthorizingPresentationType()) + .thenReturn(SessionAuthorizingPresentation.Type.QUICK_CONNECT); + final List mockBuffer = Mockito.mock(List.class); + setInternalState(assuranceSessionOrchestrator, "outboundEventBuffer", mockBuffer); + + final AssuranceSessionOrchestrator.SessionUIOperationHandler sessionUIOperationHandler = + assuranceSessionOrchestrator.getSessionUIOperationHandler(); + Assert.assertNotNull(sessionUIOperationHandler); + + sessionUIOperationHandler.onQuickConnect( + "SessionID", "1234", mockAuthorizingPresentationListener); + + // verify existing session is terminated + verify(mockAssuranceSession) + .unregisterStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceSession).disconnect(); + verify(mockAssuranceSession) + .unregisterStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceStateManager).clearAssuranceSharedState(); + + // make sure that the existing buffer is not cleared because this is a retry + verify(mockBuffer, times(0)).clear(); + verify(mockAssuranceSession) + .unregisterStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceSession).disconnect(); + + // verify that a new session connection + verify(newMockSession).connect("1234"); + verify(newMockSession) + .registerStatusListener( + assuranceSessionOrchestrator.getAssuranceSessionStatusListener()); + verify(mockAssuranceStateManager).shareAssuranceSharedState("SessionID"); + } } diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManagerTest.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManagerTest.java index 1e2ca66..5c9e8fd 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManagerTest.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionPresentationManagerTest.java @@ -39,13 +39,18 @@ public class AssuranceSessionPresentationManagerTest extends TestCase { @Mock private AssuranceConnectionStatusUI mockConnectionStatusUI; - @Mock private AssurancePinCodeEntryURLProvider mockPinCodeEntryURLProvider; + @Mock private SessionAuthorizingPresentation mockSessionAuthorizingPresentation; @Mock private AssuranceStateManager mockAssuranceStateManager; @Mock private AssuranceSessionOrchestrator.SessionUIOperationHandler mockSessionUIOperationHandler; + @Mock + private AssuranceSession.AssuranceSessionStatusListener mockAssuranceSessionStatusListener; + + @Mock private SessionAuthorizingPresentation.Type mockSessionAuthorizingPresentationType; + @Before public void setUp() throws Exception { MockitoAnnotations.openMocks(this); @@ -54,12 +59,16 @@ public void setUp() throws Exception { new AssuranceSessionPresentationManager( mockAssuranceStateManager, mockSessionUIOperationHandler, - mockApplicationHandle); + mockApplicationHandle, + mockSessionAuthorizingPresentationType, + mockAssuranceSessionStatusListener); setInternalState( assuranceSessionPresentationManager, "button", mockAssuranceFloatingButton); setInternalState(assuranceSessionPresentationManager, "statusUI", mockConnectionStatusUI); setInternalState( - assuranceSessionPresentationManager, "urlProvider", mockPinCodeEntryURLProvider); + assuranceSessionPresentationManager, + "authorizingPresentation", + mockSessionAuthorizingPresentation); } @Test @@ -77,20 +86,20 @@ public void testLogLocalUI_AddsUILog() { public void testOnSessionInitialized_LaunchesPinDiaLog() { assuranceSessionPresentationManager.onSessionInitialized(); - verify(mockPinCodeEntryURLProvider).launchPinDialog(); + verify(mockSessionAuthorizingPresentation).showAuthorization(); } @Test public void testOnSessionConnecting_NotifiesPINProvider() { assuranceSessionPresentationManager.onSessionConnecting(); - verify(mockPinCodeEntryURLProvider).onConnecting(); + verify(mockSessionAuthorizingPresentation).onConnecting(); } @Test public void testOnSessionConnected_NotifiesPINDialog_ShowsFloatingButton_LogsUI() { assuranceSessionPresentationManager.onSessionConnected(); - verify(mockPinCodeEntryURLProvider).onConnectionSucceeded(); + verify(mockSessionAuthorizingPresentation).onConnectionSucceeded(); verify(mockAssuranceFloatingButton) .setCurrentGraphic(AssuranceFloatingButtonView.Graphic.CONNECTED); verify(mockAssuranceFloatingButton).display(); @@ -182,7 +191,7 @@ public void testOnSessionDisconnected_SESSION_DELETED() { public void testOnSessionDisconnected_ABNORMAL() { final Activity mockActivity = mock(Activity.class); when(mockApplicationHandle.getCurrentActivity()).thenReturn(mockActivity); - when(mockPinCodeEntryURLProvider.isDisplayed()).thenReturn(false); + when(mockSessionAuthorizingPresentation.isDisplayed()).thenReturn(false); assuranceSessionPresentationManager.onSessionDisconnected( AssuranceConstants.SocketCloseCode.ABNORMAL); @@ -242,14 +251,10 @@ public void testOnSessionStateChange() { public void testOnActivityResumed() { final Activity mockActivity = mock(Activity.class); when(mockApplicationHandle.getCurrentActivity()).thenReturn(mockActivity); - final Runnable mockDeferredRunnable = mock(Runnable.class); - mockPinCodeEntryURLProvider.deferredActivityRunnable = mockDeferredRunnable; assuranceSessionPresentationManager.onActivityResumed(mockActivity); verify(mockAssuranceFloatingButton).onActivityResumed(mockActivity); - verify(mockDeferredRunnable).run(); - assertNull(mockPinCodeEntryURLProvider.deferredActivityRunnable); } @Test diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionTest.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionTest.java index de9ac91..14d9348 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionTest.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceSessionTest.java @@ -16,6 +16,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -62,6 +63,8 @@ public class AssuranceSessionTest { @Mock AssuranceSessionPresentationManager mockAssuranceSessionPresentationManager; + @Mock AssuranceSession.AssuranceSessionStatusListener mockAssuranceSessionStatusListener; + @Mock AssuranceWebViewSocket mockAssuranceWebViewSocket; @Mock AssurancePluginManager mockAssurancePluginManager; @@ -91,28 +94,7 @@ public void setup() { when(mockAssuranceStateManager.getOrgId(true)).thenReturn(mockOrgId); when(mockAssuranceStateManager.getClientId()).thenReturn(mockClientId); - // create the instance of the griffon session - assuranceSession = - new AssuranceSession( - mockApplicationHandle, - mockAssuranceStateManager, - "SampleSessionID", - AssuranceConstants.AssuranceEnvironment.PROD, - mockAssuranceConnectionDataStore, - mockSessionUIOperationHandler, - Collections.EMPTY_LIST, - Collections.EMPTY_LIST); - - // Assign mocks to private fields instantiated inside the constructor - setInternalState( - assuranceSession, - "assuranceSessionPresentationManager", - mockAssuranceSessionPresentationManager); - setInternalState(assuranceSession, "inboundEventQueueWorker", mockInboundEventQueueWorker); - setInternalState( - assuranceSession, "outboundEventQueueWorker", mockOutboundEventQueueWorker); - setInternalState(assuranceSession, "socket", mockAssuranceWebViewSocket); - setInternalState(assuranceSession, "pluginManager", mockAssurancePluginManager); + assuranceSession = createSession(mock(SessionAuthorizingPresentation.Type.class)); } @Test @@ -359,7 +341,60 @@ public Object answer(InvocationOnMock invocationOnMock) } @Test - public void test_onSocketDisconnected_ABNORMAL_retry() throws Exception { + public void test_onSocketDisconnected_ABNORMAL_PinAuthorizingScreenDisplayed() + throws Exception { + assuranceSession = createSession(SessionAuthorizingPresentation.Type.PIN); + final Handler mockHandler = Mockito.mock(Handler.class); + setInternalState(assuranceSession, "socketReconnectHandler", mockHandler); + doAnswer( + new Answer() { + @Override + public Object answer(InvocationOnMock invocationOnMock) + throws Throwable { + Runnable runnable = (Runnable) invocationOnMock.getArgument(0); + runnable.run(); + return null; + } + }) + .when(mockHandler) + .postDelayed(any(Runnable.class), anyLong()); + + final String sessionID = "sampleSessionId"; + final String token = "1234"; + final String orgId = "sampleOrgId"; + final String clientID = "sampleClientId"; + final String mockConnectionURL = buildURL(sessionID, token, orgId, clientID); + when(mockAssuranceConnectionDataStore.getStoredConnectionURL()) + .thenReturn(mockConnectionURL); + + when(mockUri.getQueryParameter("sessionId")).thenReturn((sessionID)); + when(mockUri.getQueryParameter("token")).thenReturn((token)); + when(mockUri.getQueryParameter("orgId")).thenReturn(orgId); + when(mockUri.getQueryParameter("clientId")).thenReturn((orgId)); + when(mockAssuranceSessionPresentationManager.isAuthorizingPresentationActive()) + .thenReturn(true); + + assuranceSession.onSocketDisconnected( + mockAssuranceWebViewSocket, + "SampleReason", + AssuranceConstants.SocketCloseCode.ABNORMAL, + false); + + verify(mockOutboundEventQueueWorker).block(); + verify(mockAssuranceSessionPresentationManager) + .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); + verify(mockAssurancePluginManager) + .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); + // Verify that reconnection does not happen automatically when authorization screen is + // active + verify(mockAssuranceSessionPresentationManager, times(0)).onSessionReconnecting(); + verify(mockAssuranceWebViewSocket, times(0)).connect(anyString()); + } + + @Test + public void test_onSocketDisconnected_ABNORMAL_QuickConnectAuthorizingUIDisplayed() + throws Exception { + assuranceSession = createSession(SessionAuthorizingPresentation.Type.QUICK_CONNECT); final Handler mockHandler = Mockito.mock(Handler.class); setInternalState(assuranceSession, "socketReconnectHandler", mockHandler); doAnswer( @@ -382,6 +417,59 @@ public Object answer(InvocationOnMock invocationOnMock) final String mockConnectionURL = buildURL(sessionID, token, orgId, clientID); when(mockAssuranceConnectionDataStore.getStoredConnectionURL()) .thenReturn(mockConnectionURL); + + when(mockUri.getQueryParameter("sessionId")).thenReturn((sessionID)); + when(mockUri.getQueryParameter("token")).thenReturn((token)); + when(mockUri.getQueryParameter("orgId")).thenReturn(orgId); + when(mockUri.getQueryParameter("clientId")).thenReturn((orgId)); + when(mockAssuranceSessionPresentationManager.isAuthorizingPresentationActive()) + .thenReturn(true); + + assuranceSession.onSocketDisconnected( + mockAssuranceWebViewSocket, + "SampleReason", + AssuranceConstants.SocketCloseCode.ABNORMAL, + false); + + verify(mockOutboundEventQueueWorker).block(); + verify(mockAssuranceSessionPresentationManager) + .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); + verify(mockAssurancePluginManager) + .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); + // Verify that reconnection does not happen automatically when authorization screen is + // active + verify(mockAssuranceSessionPresentationManager, times(0)).onSessionReconnecting(); + verify(mockAssuranceWebViewSocket, times(0)).connect(anyString()); + } + + @Test + public void test_onSocketDisconnected_ABNORMAL_PinAuthorizingUINotDisplayed() throws Exception { + assuranceSession = createSession(SessionAuthorizingPresentation.Type.PIN); + + final Handler mockHandler = Mockito.mock(Handler.class); + setInternalState(assuranceSession, "socketReconnectHandler", mockHandler); + doAnswer( + new Answer() { + @Override + public Object answer(InvocationOnMock invocationOnMock) + throws Throwable { + Runnable runnable = (Runnable) invocationOnMock.getArgument(0); + runnable.run(); + return null; + } + }) + .when(mockHandler) + .postDelayed(any(Runnable.class), anyLong()); + + final String sessionID = "sampleSessionId"; + final String token = "1234"; + final String orgId = "sampleOrgId"; + final String clientID = "sampleClientId"; + final String mockConnectionURL = buildURL(sessionID, token, orgId, clientID); + when(mockAssuranceConnectionDataStore.getStoredConnectionURL()) + .thenReturn(mockConnectionURL); + when(mockAssuranceSessionPresentationManager.isAuthorizingPresentationActive()) + .thenReturn(false); when(mockUri.getQueryParameter("sessionId")).thenReturn((sessionID)); when(mockUri.getQueryParameter("token")).thenReturn((token)); when(mockUri.getQueryParameter("orgId")).thenReturn(orgId); @@ -398,6 +486,65 @@ public Object answer(InvocationOnMock invocationOnMock) AssuranceConstants.SocketCloseCode.ABNORMAL, true); + // Verify that reconnection attempt occurs + verify(mockOutboundEventQueueWorker, times(2)).block(); + verify(mockAssuranceSessionPresentationManager, times(2)) + .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); + verify(mockAssuranceWebViewSocket, times(2)).connect(anyString()); + + // Plugins should only be notified once + verify(mockAssurancePluginManager, times(1)) + .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); + // Reconnecting message should only be printed once. + verify(mockAssuranceSessionPresentationManager, times(1)).onSessionReconnecting(); + } + + @Test + public void test_onSocketDisconnected_ABNORMAL_QuickConnectAuthorizingUINotDisplayed() + throws Exception { + assuranceSession = createSession(SessionAuthorizingPresentation.Type.QUICK_CONNECT); + + final Handler mockHandler = Mockito.mock(Handler.class); + setInternalState(assuranceSession, "socketReconnectHandler", mockHandler); + doAnswer( + new Answer() { + @Override + public Object answer(InvocationOnMock invocationOnMock) + throws Throwable { + Runnable runnable = (Runnable) invocationOnMock.getArgument(0); + runnable.run(); + return null; + } + }) + .when(mockHandler) + .postDelayed(any(Runnable.class), anyLong()); + + final String sessionID = "sampleSessionId"; + final String token = "1234"; + final String orgId = "sampleOrgId"; + final String clientID = "sampleClientId"; + final String mockConnectionURL = buildURL(sessionID, token, orgId, clientID); + when(mockAssuranceConnectionDataStore.getStoredConnectionURL()) + .thenReturn(mockConnectionURL); + when(mockAssuranceSessionPresentationManager.isAuthorizingPresentationActive()) + .thenReturn(false); + when(mockUri.getQueryParameter("sessionId")).thenReturn((sessionID)); + when(mockUri.getQueryParameter("token")).thenReturn((token)); + when(mockUri.getQueryParameter("orgId")).thenReturn(orgId); + when(mockUri.getQueryParameter("clientId")).thenReturn((orgId)); + + assuranceSession.onSocketDisconnected( + mockAssuranceWebViewSocket, + "SampleReason", + AssuranceConstants.SocketCloseCode.ABNORMAL, + true); + assuranceSession.onSocketDisconnected( + mockAssuranceWebViewSocket, + "SampleReason", + AssuranceConstants.SocketCloseCode.ABNORMAL, + true); + + // Verify that reconnection attempt occurs verify(mockOutboundEventQueueWorker, times(2)).block(); verify(mockAssuranceSessionPresentationManager, times(2)) .onSessionDisconnected(AssuranceConstants.SocketCloseCode.ABNORMAL); @@ -473,6 +620,38 @@ public void teardown() { mockedStaticMobileCore.close(); } + private AssuranceSession createSession( + final SessionAuthorizingPresentation.Type authorizingPresentationType) { + final AssuranceSession assuranceSession = + new AssuranceSession( + mockApplicationHandle, + mockAssuranceStateManager, + "SampleSessionID", + AssuranceConstants.AssuranceEnvironment.PROD, + mockAssuranceConnectionDataStore, + mockSessionUIOperationHandler, + Collections.EMPTY_LIST, + Collections.EMPTY_LIST, + authorizingPresentationType, + authorizingPresentationType + == SessionAuthorizingPresentation.Type.QUICK_CONNECT + ? mockAssuranceSessionStatusListener + : null); + + // Assign mocks to private fields instantiated inside the constructor + setInternalState( + assuranceSession, + "assuranceSessionPresentationManager", + mockAssuranceSessionPresentationManager); + setInternalState(assuranceSession, "inboundEventQueueWorker", mockInboundEventQueueWorker); + setInternalState( + assuranceSession, "outboundEventQueueWorker", mockOutboundEventQueueWorker); + setInternalState(assuranceSession, "socket", mockAssuranceWebViewSocket); + setInternalState(assuranceSession, "pluginManager", mockAssurancePluginManager); + + return assuranceSession; + } + private String buildURL( final String sessionID, final String token, final String orgId, final String clientID) throws Exception { diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTest.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTest.java index 4a96026..1cb6520 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTest.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTest.java @@ -11,7 +11,8 @@ package com.adobe.marketing.mobile.assurance; -import static junit.framework.TestCase.assertEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.times; @@ -23,6 +24,7 @@ import com.adobe.marketing.mobile.ExtensionError; import com.adobe.marketing.mobile.ExtensionErrorCallback; import com.adobe.marketing.mobile.MobileCore; +import com.adobe.marketing.mobile.util.DataReader; import junit.framework.TestCase; import org.junit.After; import org.junit.Before; @@ -110,6 +112,29 @@ public void test_RegisterExtension() { extensionErrorCallbackCaptor.getValue().error(ExtensionError.UNEXPECTED_ERROR); } + @Test + public void test_startSession_quickConnect() { + // prepare + final ArgumentCaptor dispatchedEventCaptor = ArgumentCaptor.forClass(Event.class); + + // test + Assurance.startSession(); + + // verify + mockedStaticMobileCore.verify( + () -> MobileCore.dispatchEvent(dispatchedEventCaptor.capture()), times(1)); + + final Event dispatchedEvent = dispatchedEventCaptor.getValue(); + assertEquals(EventType.ASSURANCE, dispatchedEvent.getType()); + assertEquals(EventSource.REQUEST_CONTENT, dispatchedEvent.getSource()); + assertEquals("Assurance Start Session (Quick Connect)", dispatchedEvent.getName()); + assertTrue( + DataReader.optBoolean( + dispatchedEvent.getEventData(), + AssuranceConstants.SDKEventDataKey.IS_QUICK_CONNECT, + false)); + } + @After public void teardown() { mockedStaticMobileCore.close(); diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTestUtils.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTestUtils.java index c07038c..e17da27 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTestUtils.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceTestUtils.java @@ -11,9 +11,18 @@ package com.adobe.marketing.mobile.assurance; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.fail; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; +import com.adobe.marketing.mobile.services.HttpConnecting; +import com.adobe.marketing.mobile.services.NetworkRequest; +import java.io.InputStream; import java.lang.reflect.Field; +import java.util.Map; +import org.mockito.Mockito; /** Utility class for tests. */ class AssuranceTestUtils { @@ -35,4 +44,52 @@ static void setInternalState(Object classToSet, String name, Object value) { fail(String.format("Failed to set %s.%s", classToSet, name)); } } + + /** + * Compares the provided {@code NetworkRequest} parameters and determines if they represent the + * same request. + * + * @param expectedNetworkRequest the {@code NetworkRequest} to be compared against + * @param actualNetworkRequest the {@code NetworkRequest} to be validated + */ + static void verifyNetworkRequestParams( + final NetworkRequest expectedNetworkRequest, + final NetworkRequest actualNetworkRequest) { + assertEquals(expectedNetworkRequest.getUrl(), actualNetworkRequest.getUrl()); + assertEquals(expectedNetworkRequest.getMethod(), actualNetworkRequest.getMethod()); + assertEquals( + new String(expectedNetworkRequest.getBody()), + new String(actualNetworkRequest.getBody())); + assertEquals( + expectedNetworkRequest.getConnectTimeout(), + actualNetworkRequest.getConnectTimeout()); + assertEquals( + expectedNetworkRequest.getReadTimeout(), actualNetworkRequest.getReadTimeout()); + assertEquals(expectedNetworkRequest.getHeaders(), actualNetworkRequest.getHeaders()); + } + + /** + * Simulates a {@code HttpConnecting} with the provided parameters. Intended for use with a + * Mockito.when() to mock network responses. + * + * @param responseCode a mock response code + * @param responseStream response to be simulated + * @param metadata headers to be simulated + * @return a mock {@code HttpConnecting} with the provided parameters + */ + static HttpConnecting simulateNetworkResponse( + int responseCode, InputStream responseStream, Map metadata) { + final HttpConnecting mockResponse = Mockito.mock(HttpConnecting.class); + when(mockResponse.getResponseCode()).thenReturn(responseCode); + when(mockResponse.getInputStream()).thenReturn(responseStream); + doAnswer( + invocation -> { + final String key = invocation.getArgument(0); + return metadata.get(key); + }) + .when(mockResponse) + .getResponsePropertyValue(any()); + + return mockResponse; + } } diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceUtilTest.java b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceUtilTest.java index 0997213..eaf19d5 100644 --- a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceUtilTest.java +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/AssuranceUtilTest.java @@ -15,7 +15,9 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import android.app.Activity; import android.net.Uri; import org.junit.Test; import org.junit.runner.RunWith; @@ -220,4 +222,20 @@ public void test_isSafe_ValidURL() { + "clientId=C8385D85-9CE3-409E-92C2-565E7E59D69C"; assertTrue(AssuranceUtil.isSafe(connectionURLProd)); } + + @Test + public void test_isAssuranceActivity() { + final Activity assuranceQuickConnectActivity = mock(AssuranceQuickConnectActivity.class); + assertTrue(AssuranceUtil.isAssuranceActivity(assuranceQuickConnectActivity)); + + final Activity assuranceErrorDisplayActivity = mock(AssuranceErrorDisplayActivity.class); + assertTrue(AssuranceUtil.isAssuranceActivity(assuranceErrorDisplayActivity)); + + final Activity assuranceFullScreenTakeoverActivity = + mock(AssuranceFullScreenTakeoverActivity.class); + assertTrue(AssuranceUtil.isAssuranceActivity(assuranceFullScreenTakeoverActivity)); + + final Activity nonAssuranceActivity = mock(Activity.class); + assertFalse(AssuranceUtil.isAssuranceActivity(nonAssuranceActivity)); + } } diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectAuthorizingPresentationTest.kt b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectAuthorizingPresentationTest.kt new file mode 100644 index 0000000..058d4e1 --- /dev/null +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectAuthorizingPresentationTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright 2022 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceSession.AssuranceSessionStatusListener +import com.adobe.marketing.mobile.services.AppContextService +import com.adobe.marketing.mobile.services.ServiceProvider +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify + +class QuickConnectAuthorizingPresentationTest { + + @Mock private lateinit var mockPresentationDelegate: AssuranceSessionStatusListener + + @Mock private lateinit var mockAppContextService: AppContextService + + @Mock private lateinit var mockQuickConnectActivity: AssuranceQuickConnectActivity + + @Mock private lateinit var mockNonQuickConnectActivity: AssuranceFullScreenTakeoverActivity + + @Mock private lateinit var mockServiceProvider: ServiceProvider + private lateinit var mockedStaticServiceProvider: MockedStatic + private lateinit var quickConnectPresentation: QuickConnectAuthorizingPresentation + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + mockedStaticServiceProvider = Mockito.mockStatic(ServiceProvider::class.java) + mockedStaticServiceProvider.`when` { ServiceProvider.getInstance() }.thenReturn(mockServiceProvider) + `when`(mockServiceProvider.appContextService).thenReturn(mockAppContextService) + + quickConnectPresentation = QuickConnectAuthorizingPresentation(mockPresentationDelegate) + } + + @Test + fun `Verify isDisplayed is true when current activity is QuickConnectActivity`() { + `when`(mockAppContextService.currentActivity).thenReturn(mockQuickConnectActivity) + + assertTrue(quickConnectPresentation.isDisplayed) + } + + @Test + fun `Verify isDisplayed is false when current activity is NOT a QuickConnectActivity`() { + `when`(mockAppContextService.currentActivity).thenReturn(mockNonQuickConnectActivity) + + assertFalse(quickConnectPresentation.isDisplayed) + } + + @Test + fun `Verify reorderToFront does nothing`() { + quickConnectPresentation.reorderToFront() + verifyNoInteractions(mockPresentationDelegate) + } + + @Test + fun `Verify showAuthorization does nothing`() { + quickConnectPresentation.showAuthorization() + verifyNoInteractions(mockPresentationDelegate) + } + + @Test + fun `Verify onConnectionSucceeded notifies delegate`() { + quickConnectPresentation.onConnectionSucceeded() + verify(mockPresentationDelegate).onSessionConnected() + } + + @Test + fun `Verify onConnectionFailed notifies delegate`() { + AssuranceConnectionError.values().forEach { + quickConnectPresentation.onConnectionFailed(it, it.isRetryable) + verify(mockPresentationDelegate).onSessionTerminated(it) + reset(mockPresentationDelegate) + } + } + + @After + fun teardown() { + mockedStaticServiceProvider.close() + } +} diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceCreatorTest.kt b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceCreatorTest.kt new file mode 100644 index 0000000..ddc74e7 --- /dev/null +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceCreatorTest.kt @@ -0,0 +1,290 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import com.adobe.marketing.mobile.AdobeCallback +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceConstants.QuickConnect +import com.adobe.marketing.mobile.assurance.AssuranceTestUtils.simulateNetworkResponse +import com.adobe.marketing.mobile.assurance.AssuranceTestUtils.verifyNetworkRequestParams +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.NetworkCallback +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.Networking +import com.adobe.marketing.mobile.services.NetworkingConstants +import com.adobe.marketing.mobile.services.ServiceProvider +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import java.net.HttpURLConnection + +class QuickConnectDeviceCreatorTest { + + companion object { + private const val TEST_ORG_ID = "SampleOrgId@AdobeOrg" + private const val TEST_CLIENT_ID = "SampleClientId" + private const val TEST_DEVICE_NAME = "SampleDeviceName" + } + + @Mock + private lateinit var mockNetworkService: Networking + + @Mock + private lateinit var mockServiceProvider: ServiceProvider + + @Mock + private lateinit var mockCallback: AdobeCallback> + + private lateinit var mockedStaticServiceProvider: MockedStatic + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + mockedStaticServiceProvider = Mockito.mockStatic(ServiceProvider::class.java) + mockedStaticServiceProvider.`when` { ServiceProvider.getInstance() }.thenReturn(mockServiceProvider) + `when`(mockServiceProvider.networkService).thenReturn(mockNetworkService) + } + + @Test + fun `Verify DeviceCreationTask makes network request`() { + // setup + val quickConnectDeviceCreator = QuickConnectDeviceCreator(TEST_ORG_ID, TEST_CLIENT_ID, TEST_DEVICE_NAME, mockCallback) + + // test + quickConnectDeviceCreator.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_DEVICE_NAME to TEST_DEVICE_NAME, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_CREATE}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + } + + @Test + fun `Verify DeviceCreationTask invoked callback with Success response when request successful`() { + // setup + val quickConnectDeviceCreator = QuickConnectDeviceCreator(TEST_ORG_ID, TEST_CLIENT_ID, TEST_DEVICE_NAME, mockCallback) + + // test + quickConnectDeviceCreator.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_DEVICE_NAME to TEST_DEVICE_NAME, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_CREATE}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + + // simulate HTTP.OK response for the request + val simulatedResponse = simulateNetworkResponse(HttpURLConnection.HTTP_OK, "ResponseData".byteInputStream(), mapOf()) + capturedNetworkCallback.call(simulatedResponse) + + val responseCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockCallback).call(responseCaptor.capture()) + + val capturedResponse: Response = responseCaptor.firstValue + if (capturedResponse is Response.Success) { + assertEquals(simulatedResponse.inputStream, capturedResponse.data.inputStream) + assertEquals(simulatedResponse.responseCode, capturedResponse.data.responseCode) + } else { + fail("Successful response should have been delivered.") + } + + verify(simulatedResponse).close() + } + + @Test + fun `Verify DeviceCreationTask invoked callback with CREATE_DEVICE_REQUEST_FAILED response when request fails`() { + // setup + val quickConnectDeviceCreator = QuickConnectDeviceCreator(TEST_ORG_ID, TEST_CLIENT_ID, TEST_DEVICE_NAME, mockCallback) + + // test + quickConnectDeviceCreator.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_DEVICE_NAME to TEST_DEVICE_NAME, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_CREATE}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + + // simulate HTTP_NOT_FOUND response for the request + val simulatedResponse = simulateNetworkResponse(HttpURLConnection.HTTP_NOT_FOUND, null, mapOf()) + capturedNetworkCallback.call(simulatedResponse) + + val responseCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockCallback).call(responseCaptor.capture()) + + val capturedResponse: Response = responseCaptor.firstValue + + if (capturedResponse is Response.Failure) { + assertEquals(AssuranceConnectionError.CREATE_DEVICE_REQUEST_FAILED, capturedResponse.error) + } else { + fail("Error response should have been delivered.") + } + + verify(simulatedResponse).close() + } + + @Test + fun `Verify DeviceCreationTask invoked callback with UNEXPECTED_ERROR response when request fails`() { + // setup + val quickConnectDeviceCreator = QuickConnectDeviceCreator(TEST_ORG_ID, TEST_CLIENT_ID, TEST_DEVICE_NAME, mockCallback) + + // test + quickConnectDeviceCreator.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_DEVICE_NAME to TEST_DEVICE_NAME, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_CREATE}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + + // simulate null response for the request + capturedNetworkCallback.call(null) + + val responseCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockCallback).call(responseCaptor.capture()) + + val capturedResponse: Response = responseCaptor.firstValue + + if (capturedResponse is Response.Failure) { + assertEquals(AssuranceConnectionError.UNEXPECTED_ERROR, capturedResponse.error) + } else { + fail("Error response should have been delivered.") + } + } + + @After + fun teardown() { + mockedStaticServiceProvider.close() + } +} diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceStatusCheckerTest.kt b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceStatusCheckerTest.kt new file mode 100644 index 0000000..8babdb5 --- /dev/null +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectDeviceStatusCheckerTest.kt @@ -0,0 +1,284 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import com.adobe.marketing.mobile.AdobeCallback +import com.adobe.marketing.mobile.assurance.AssuranceConstants.QuickConnect +import com.adobe.marketing.mobile.assurance.AssuranceTestUtils.simulateNetworkResponse +import com.adobe.marketing.mobile.assurance.AssuranceTestUtils.verifyNetworkRequestParams +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.HttpMethod +import com.adobe.marketing.mobile.services.NetworkCallback +import com.adobe.marketing.mobile.services.NetworkRequest +import com.adobe.marketing.mobile.services.Networking +import com.adobe.marketing.mobile.services.NetworkingConstants +import com.adobe.marketing.mobile.services.ServiceProvider +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import java.net.HttpURLConnection + +class QuickConnectDeviceStatusCheckerTest { + + companion object { + private const val TEST_ORG_ID = "SampleOrgId@AdobeOrg" + private const val TEST_CLIENT_ID = "SampleClientId" + } + + @Mock + private lateinit var mockNetworkService: Networking + + @Mock + private lateinit var mockServiceProvider: ServiceProvider + + @Mock + private lateinit var mockCallback: AdobeCallback> + + private lateinit var mockedStaticServiceProvider: MockedStatic + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + mockedStaticServiceProvider = Mockito.mockStatic(ServiceProvider::class.java) + mockedStaticServiceProvider.`when` { ServiceProvider.getInstance() }.thenReturn(mockServiceProvider) + Mockito.`when`(mockServiceProvider.networkService).thenReturn(mockNetworkService) + } + + @Test + fun `Verify DeviceStatusCheckerTask makes network request`() { + // setup + val quickConnectDeviceStatusChecker = QuickConnectDeviceStatusChecker(TEST_ORG_ID, TEST_CLIENT_ID, mockCallback) + + // test + quickConnectDeviceStatusChecker.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_STATUS}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + } + + @Test + fun `Verify DeviceStatusCheckerTask invoked callback with Success response when request successful`() { + // setup + val quickConnectDeviceStatusChecker = QuickConnectDeviceStatusChecker(TEST_ORG_ID, TEST_CLIENT_ID, mockCallback) + + // test + quickConnectDeviceStatusChecker.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_STATUS}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + + // simulate HTTP.OK response for the request + val simulatedResponse = simulateNetworkResponse(HttpURLConnection.HTTP_OK, "ResponseData".byteInputStream(), mapOf()) + capturedNetworkCallback.call(simulatedResponse) + + val responseCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockCallback).call(responseCaptor.capture()) + + val capturedResponse: Response = responseCaptor.firstValue + if (capturedResponse is Response.Success) { + assertEquals(simulatedResponse.inputStream, capturedResponse.data.inputStream) + assertEquals(simulatedResponse.responseCode, capturedResponse.data.responseCode) + } else { + fail("Successful response should have been delivered.") + } + + verify(simulatedResponse).close() + } + + @Test + fun `Verify DeviceStatusCheckerTask invokes callback with DEVICE_STATUS_REQUEST_FAILED response when request fails`() { + // setup + val quickConnectDeviceStatusChecker = QuickConnectDeviceStatusChecker(TEST_ORG_ID, TEST_CLIENT_ID, mockCallback) + + // test + quickConnectDeviceStatusChecker.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_STATUS}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + + // simulate HTTP_NOT_FOUND response for the request + val simulatedResponse = simulateNetworkResponse(HttpURLConnection.HTTP_NOT_FOUND, null, mapOf()) + capturedNetworkCallback.call(simulatedResponse) + + val responseCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockCallback).call(responseCaptor.capture()) + + val capturedResponse: Response = responseCaptor.firstValue + + if (capturedResponse is Response.Failure) { + assertEquals(AssuranceConstants.AssuranceConnectionError.DEVICE_STATUS_REQUEST_FAILED, capturedResponse.error) + } else { + fail("Error response should have been delivered.") + } + + verify(simulatedResponse).close() + } + + @Test + fun `Verify DeviceStatusCheckerTask invokes callback with UNEXPECTED_ERROR response when request fails`() { + // setup + val quickConnectDeviceStatusChecker = QuickConnectDeviceStatusChecker(TEST_ORG_ID, TEST_CLIENT_ID, mockCallback) + + // test + quickConnectDeviceStatusChecker.run() + + // Verify + val networkRequestCaptor: KArgumentCaptor = argumentCaptor() + val networkCallbackCaptor: KArgumentCaptor = argumentCaptor() + verify(mockNetworkService).connectAsync(networkRequestCaptor.capture(), networkCallbackCaptor.capture()) + + val capturedNetworkRequest = networkRequestCaptor.firstValue + assertNotNull(capturedNetworkRequest) + + val expectedBody = JSONObject( + mapOf( + QuickConnect.KEY_ORG_ID to TEST_ORG_ID, + QuickConnect.KEY_CLIENT_ID to TEST_CLIENT_ID + ) + ).toString().toByteArray() + + val expectedNetworkRequest = NetworkRequest( + "${QuickConnect.BASE_DEVICE_API_URL}/${QuickConnect.DEVICE_API_PATH_STATUS}", + HttpMethod.POST, + expectedBody, + mapOf( + NetworkingConstants.Headers.ACCEPT to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION, + NetworkingConstants.Headers.CONTENT_TYPE to NetworkingConstants.HeaderValues.CONTENT_TYPE_JSON_APPLICATION + ), + QuickConnect.CONNECTION_TIMEOUT_MS, + QuickConnect.READ_TIMEOUT_MS + ) + + verifyNetworkRequestParams(expectedNetworkRequest, capturedNetworkRequest) + + val capturedNetworkCallback = networkCallbackCaptor.firstValue + assertNotNull(capturedNetworkCallback) + + // simulate null response for the request + capturedNetworkCallback.call(null) + + val responseCaptor: KArgumentCaptor> = argumentCaptor() + verify(mockCallback).call(responseCaptor.capture()) + + val capturedResponse: Response = responseCaptor.firstValue + + if (capturedResponse is Response.Failure) { + assertEquals(AssuranceConstants.AssuranceConnectionError.UNEXPECTED_ERROR, capturedResponse.error) + } else { + fail("Error response should have been delivered.") + } + } + + @After + fun teardown() { + mockedStaticServiceProvider.close() + } +} diff --git a/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectManagerTest.kt b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectManagerTest.kt new file mode 100644 index 0000000..24c08a8 --- /dev/null +++ b/code/assurance/src/test/java/com/adobe/marketing/mobile/assurance/QuickConnectManagerTest.kt @@ -0,0 +1,307 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +package com.adobe.marketing.mobile.assurance + +import com.adobe.marketing.mobile.assurance.AssuranceConstants.AssuranceConnectionError +import com.adobe.marketing.mobile.assurance.AssuranceConstants.QuickConnect +import com.adobe.marketing.mobile.assurance.AssuranceTestUtils.simulateNetworkResponse +import com.adobe.marketing.mobile.services.DeviceInforming +import com.adobe.marketing.mobile.services.HttpConnecting +import com.adobe.marketing.mobile.services.Networking +import com.adobe.marketing.mobile.services.ServiceProvider +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockedStatic +import org.mockito.Mockito +import org.mockito.Mockito.any +import org.mockito.Mockito.anyLong +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.KArgumentCaptor +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import java.util.concurrent.Future +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import javax.net.ssl.HttpsURLConnection + +class QuickConnectManagerTest { + + companion object { + private const val TEST_ORG_ID = "SampleOrgId@AdobeOrg" + private const val TEST_CLIENT_ID = "SampleClientId" + private const val TEST_DEVICE_NAME = "SampleDeviceName" + } + + @Mock + private lateinit var mockAssuranceStateManager: AssuranceStateManager + + @Mock + private lateinit var mockServiceProvider: ServiceProvider + + @Mock + private lateinit var mockNetworkService: Networking + + @Mock + private lateinit var mockDeviceInfoService: DeviceInforming + + @Mock + private lateinit var mockExecutorService: ScheduledExecutorService + + @Mock + private lateinit var mockQuickConnectCallback: QuickConnectCallback + + @Mock + private lateinit var response: HttpConnecting + + private lateinit var mockedStaticServiceProvider: MockedStatic + private lateinit var quickConnectManager: QuickConnectManager + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + mockedStaticServiceProvider = Mockito.mockStatic(ServiceProvider::class.java) + mockedStaticServiceProvider.`when` { ServiceProvider.getInstance() }.thenReturn(mockServiceProvider) + + `when`(mockServiceProvider.deviceInfoService).thenReturn(mockDeviceInfoService) + `when`(mockServiceProvider.networkService).thenReturn(mockNetworkService) + + quickConnectManager = QuickConnectManager( + mockAssuranceStateManager, + mockExecutorService, + mockQuickConnectCallback + ) + } + + @Test + fun `Invoking register launches DeviceCreationTask`() { + `when`(mockAssuranceStateManager.clientId).thenReturn(TEST_CLIENT_ID) + `when`(mockAssuranceStateManager.getOrgId(false)).thenReturn(TEST_CLIENT_ID) + `when`(mockDeviceInfoService.deviceName).thenReturn(TEST_DEVICE_NAME) + + quickConnectManager.registerDevice() + + val quickConnectDeviceCreatorCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).submit(quickConnectDeviceCreatorCaptor.capture()) + + val capturedDeviceCreationTask = quickConnectDeviceCreatorCaptor.firstValue + assertNotNull(capturedDeviceCreationTask) + } + + @Test + fun `Invoking register called multiple times without cancelling launches DeviceCreationTask only once`() { + `when`(mockAssuranceStateManager.clientId).thenReturn(TEST_CLIENT_ID) + `when`(mockAssuranceStateManager.getOrgId(false)).thenReturn(TEST_CLIENT_ID) + `when`(mockDeviceInfoService.deviceName).thenReturn(TEST_DEVICE_NAME) + + quickConnectManager.registerDevice() + quickConnectManager.registerDevice() + quickConnectManager.registerDevice() + + val quickConnectDeviceCreatorCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService, times(1)).submit(quickConnectDeviceCreatorCaptor.capture()) + + val capturedDeviceCreationTask = quickConnectDeviceCreatorCaptor.firstValue + assertNotNull(capturedDeviceCreationTask) + } + + @Test + fun `Invoking register results in launching DeviceStatusCheckerTask on successful response`() { + // setup + `when`(mockAssuranceStateManager.clientId).thenReturn(TEST_CLIENT_ID) + `when`(mockAssuranceStateManager.getOrgId(false)).thenReturn(TEST_CLIENT_ID) + `when`(mockDeviceInfoService.deviceName).thenReturn(TEST_DEVICE_NAME) + + // test + quickConnectManager.registerDevice() + + // verify + val quickConnectDeviceCreatorCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).submit(quickConnectDeviceCreatorCaptor.capture()) + + val capturedDeviceCreationTask = quickConnectDeviceCreatorCaptor.firstValue + assertNotNull(capturedDeviceCreationTask) + + // simulate successful response + val quickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + capturedDeviceCreationTask.getCallback().call(Response.Success(response)) + + verify(mockExecutorService).schedule(quickConnectDeviceStatusCheckerCaptor.capture(), eq(QuickConnect.STATUS_CHECK_DELAY_MS), eq(TimeUnit.MILLISECONDS)) + + val capturedDeviceStatusCheckerTask = quickConnectDeviceStatusCheckerCaptor.firstValue + assertNotNull(capturedDeviceStatusCheckerTask) + } + + @Test + fun `Invoking register calls onError on failed device creation request`() { + // setup + `when`(mockAssuranceStateManager.clientId).thenReturn(TEST_CLIENT_ID) + `when`(mockAssuranceStateManager.getOrgId(false)).thenReturn(TEST_CLIENT_ID) + `when`(mockDeviceInfoService.deviceName).thenReturn(TEST_DEVICE_NAME) + + // test + quickConnectManager.registerDevice() + + // verify + val quickConnectDeviceCreatorCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).submit(quickConnectDeviceCreatorCaptor.capture()) + + val capturedDeviceCreationTask = quickConnectDeviceCreatorCaptor.firstValue + assertNotNull(capturedDeviceCreationTask) + + // simulate REQUEST_FAILED response + val quickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + capturedDeviceCreationTask.getCallback().call(Response.Failure(AssuranceConnectionError.CREATE_DEVICE_REQUEST_FAILED)) + verify(mockExecutorService, never()).schedule(quickConnectDeviceStatusCheckerCaptor.capture(), anyLong(), any(TimeUnit::class.java)) + + verify(mockQuickConnectCallback).onError(AssuranceConnectionError.CREATE_DEVICE_REQUEST_FAILED) + + // simulate UNEXPECTED_ERROR response + capturedDeviceCreationTask.getCallback().call(Response.Failure(AssuranceConnectionError.UNEXPECTED_ERROR)) + verify(mockExecutorService, never()).schedule(quickConnectDeviceStatusCheckerCaptor.capture(), anyLong(), any(TimeUnit::class.java)) + + verify(mockQuickConnectCallback).onError(AssuranceConnectionError.UNEXPECTED_ERROR) + } + + @Test + fun `Check status retries on a successful response without session details`() { + // Setup + `when`(mockAssuranceStateManager.clientId).thenReturn(TEST_CLIENT_ID) + `when`(mockAssuranceStateManager.getOrgId(false)).thenReturn(TEST_CLIENT_ID) + `when`(mockDeviceInfoService.deviceName).thenReturn(TEST_DEVICE_NAME) + // simulate device registration and "activeness" + quickConnectManager.registerDevice() + + // Test + quickConnectManager.checkDeviceStatus(TEST_ORG_ID, TEST_CLIENT_ID) + + val quickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).schedule(quickConnectDeviceStatusCheckerCaptor.capture(), anyLong(), any(TimeUnit::class.java)) + + val capturedDeviceStatusCheckerTask = quickConnectDeviceStatusCheckerCaptor.firstValue + assertNotNull(capturedDeviceStatusCheckerTask) + + reset(mockExecutorService) + + val simulatedResponse = simulateNetworkResponse( + HttpsURLConnection.HTTP_OK, + JSONObject(emptyMap()).toString().byteInputStream(), // no session information + mapOf() + ) + + capturedDeviceStatusCheckerTask.getCallback().call(Response.Success(simulatedResponse)) + + val retryQuickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).schedule(retryQuickConnectDeviceStatusCheckerCaptor.capture(), eq(QuickConnect.STATUS_CHECK_DELAY_MS), eq(TimeUnit.MILLISECONDS)) + val capturedRetryDeviceStatusCheckerTask = retryQuickConnectDeviceStatusCheckerCaptor.firstValue + assertNotNull(capturedRetryDeviceStatusCheckerTask) + } + + @Test + fun `Check status calls QuickConnectCallback-onSuccess on a successful response with session details`() { + val mockStatusCheckFuture = mock>() + `when`(mockExecutorService.schedule(any(QuickConnectDeviceStatusChecker::class.java), anyLong(), any(TimeUnit::class.java))).thenReturn(mockStatusCheckFuture) + + // test + quickConnectManager.checkDeviceStatus(TEST_ORG_ID, TEST_CLIENT_ID) + + val quickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).schedule(quickConnectDeviceStatusCheckerCaptor.capture(), anyLong(), any(TimeUnit::class.java)) + + val capturedDeviceStatusCheckerTask = quickConnectDeviceStatusCheckerCaptor.firstValue + assertNotNull(capturedDeviceStatusCheckerTask) + + reset(mockExecutorService) + + val simulatedResponse = simulateNetworkResponse( + HttpsURLConnection.HTTP_OK, + JSONObject( + mapOf( + QuickConnect.KEY_SESSION_ID to "SampleSessionID", + QuickConnect.KEY_SESSION_TOKEN to "SampleToken" + ) + ).toString().byteInputStream(), + mapOf() + ) + + // simulate successful response + capturedDeviceStatusCheckerTask.getCallback().call(Response.Success(simulatedResponse)) + + val retryQuickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService, never()).schedule(retryQuickConnectDeviceStatusCheckerCaptor.capture(), anyLong(), any(TimeUnit::class.java)) + + verify(mockQuickConnectCallback).onSuccess("SampleSessionID", "SampleToken") + + verify(mockStatusCheckFuture).cancel(true) + assertNull(quickConnectManager.deviceCreationTaskHandle) + assertNull(quickConnectManager.deviceStatusTaskHandle) + assertFalse(quickConnectManager.isActive) + } + + @Test + fun `Cancel cleans up all existing tasks`() { + // Setup + val mockCreatorFuture = mock>() + val mockStatusCheckFuture = mock>() + + `when`(mockAssuranceStateManager.clientId).thenReturn(TEST_CLIENT_ID) + `when`(mockAssuranceStateManager.getOrgId(false)).thenReturn(TEST_CLIENT_ID) + `when`(mockDeviceInfoService.deviceName).thenReturn(TEST_DEVICE_NAME) + `when`(mockExecutorService.submit(any(QuickConnectDeviceCreator::class.java))).thenReturn(mockCreatorFuture) + `when`(mockExecutorService.schedule(any(QuickConnectDeviceStatusChecker::class.java), anyLong(), any(TimeUnit::class.java))).thenReturn(mockStatusCheckFuture) + + // trigger registration + quickConnectManager.registerDevice() + + val quickConnectDeviceCreatorCaptor: KArgumentCaptor = argumentCaptor() + verify(mockExecutorService).submit(quickConnectDeviceCreatorCaptor.capture()) + + val capturedDeviceCreationTask = quickConnectDeviceCreatorCaptor.firstValue + assertNotNull(capturedDeviceCreationTask) + + // simulate successful response to simulate status check + val quickConnectDeviceStatusCheckerCaptor: KArgumentCaptor = argumentCaptor() + capturedDeviceCreationTask.getCallback().call(Response.Success(response)) + + verify(mockExecutorService).schedule(quickConnectDeviceStatusCheckerCaptor.capture(), eq(QuickConnect.STATUS_CHECK_DELAY_MS), eq(TimeUnit.MILLISECONDS)) + + val capturedDeviceStatusCheckerTask = quickConnectDeviceStatusCheckerCaptor.firstValue + assertNotNull(capturedDeviceStatusCheckerTask) + + // Test + quickConnectManager.cancel() + + verify(mockCreatorFuture).cancel(true) + verify(mockStatusCheckFuture).cancel(true) + assertNull(quickConnectManager.deviceCreationTaskHandle) + assertNull(quickConnectManager.deviceStatusTaskHandle) + assertFalse(quickConnectManager.isActive) + } + + @After + fun teardown() { + mockedStaticServiceProvider.close() + } +} diff --git a/code/build.gradle b/code/build.gradle index f2f88f4..ee558dc 100644 --- a/code/build.gradle +++ b/code/build.gradle @@ -31,6 +31,8 @@ ext { // kotlin config kotlinJvmTarget = "1.8" + kotlinApiVersion = "1.4" + kotlinLanguageVersion = "1.4" // dependencies junitVersion = "4.12" diff --git a/code/gradle.properties b/code/gradle.properties index fc62777..d97dbc0 100644 --- a/code/gradle.properties +++ b/code/gradle.properties @@ -21,7 +21,7 @@ org.gradle.configureondemand = false moduleProjectName=assurance moduleName=assurance moduleAARName=assurance-phone-release.aar -moduleVersion=2.0.1 +moduleVersion=2.1.0 mavenRepoName=AdobeMobileAssurance mavenRepoDescription=Android Assurance Extension for Adobe Mobile Marketing