diff --git a/CHANGELOG.md b/CHANGELOG.md index 579711f87..94f70b3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## XX.XX.XX +* Added Content feature method `previewContent(String contentId)` (Experimental!). * Improved content display and refresh mechanics. * Mitigated an issue about health checks storage in explicit storage mode. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java new file mode 100644 index 000000000..521a48aee --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -0,0 +1,161 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class ModuleContentTests { + + Countly mCountly; + List capturedRequests; + List capturedEndpoints; + + @Before + public void setUp() { + TestUtils.getCountlyStore().clear(); + capturedRequests = new ArrayList<>(); + capturedEndpoints = new ArrayList<>(); + } + + @After + public void tearDown() { + } + + private ImmediateRequestGenerator createCapturingIRGenerator() { + return new ImmediateRequestGenerator() { + @Override public ImmediateRequestI CreateImmediateRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + capturedRequests.add(requestData); + capturedEndpoints.add(customEndpoint); + }; + } + + @Override public ImmediateRequestI CreatePreflightRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + }; + } + }; + } + + private Countly initWithConsent(boolean contentConsent) { + CountlyConfig config = TestUtils.createBaseConfig(); + config.setRequiresConsent(true); + if (contentConsent) { + config.setConsentEnabled(new String[] { Countly.CountlyFeatureNames.content }); + } + config.disableHealthCheck(); + config.immediateRequestGenerator = createCapturingIRGenerator(); + + mCountly = new Countly(); + mCountly.init(config); + mCountly.moduleContent.countlyTimer = null; + capturedRequests.clear(); + capturedEndpoints.clear(); + return mCountly; + } + + private void setIsCurrentlyInContentZone(ModuleContent module, boolean value) throws Exception { + java.lang.reflect.Field field = ModuleContent.class.getDeclaredField("isCurrentlyInContentZone"); + field.setAccessible(true); + field.set(module, value); + } + + // ======== previewContent public API tests ======== + + /** + * Null and empty contentId should be rejected at the public API level. + * No request should be made. + */ + @Test + public void previewContent_invalidContentId() { + Countly countly = initWithConsent(true); + + countly.contents().previewContent(null); + Assert.assertEquals(0, capturedRequests.size()); + + countly.contents().previewContent(""); + Assert.assertEquals(0, capturedRequests.size()); + } + + /** + * Valid contentId with consent should make a request to /o/sdk/content + * containing content_id and preview=true parameters + */ + @Test + public void previewContent_validContentId() { + Countly countly = initWithConsent(true); + + countly.contents().previewContent("test_content_123"); + + Assert.assertEquals(1, capturedRequests.size()); + Assert.assertEquals("/o/sdk/content", capturedEndpoints.get(0)); + + String request = capturedRequests.get(0); + Assert.assertTrue(request.contains("content_id=test_content_123")); + Assert.assertTrue(request.contains("preview=true")); + } + + /** + * Without content consent, no request should be made + */ + @Test + public void previewContent_noConsent() { + Countly countly = initWithConsent(false); + + countly.contents().previewContent("test_content_id"); + + Assert.assertEquals(0, capturedRequests.size()); + } + + /** + * When content is already being displayed, no new request should be made + */ + @Test + public void previewContent_alreadyInContentZone() throws Exception { + Countly countly = initWithConsent(true); + setIsCurrentlyInContentZone(countly.moduleContent, true); + + countly.contents().previewContent("test_content_id"); + + Assert.assertEquals(0, capturedRequests.size()); + } + + // ======== validateResponse tests ======== + + /** + * validateResponse returns true only when both "geo" and "html" are present, + * false for missing geo, missing html, or empty response + */ + @Test + public void validateResponse() throws JSONException { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + // empty + Assert.assertFalse(mc.validateResponse(new JSONObject())); + + // missing geo + JSONObject noGeo = new JSONObject(); + noGeo.put("html", ""); + Assert.assertFalse(mc.validateResponse(noGeo)); + + // missing html + JSONObject noHtml = new JSONObject(); + noHtml.put("geo", new JSONObject()); + Assert.assertFalse(mc.validateResponse(noHtml)); + + // valid + JSONObject valid = new JSONObject(); + valid.put("geo", new JSONObject()); + valid.put("html", ""); + Assert.assertTrue(mc.validateResponse(valid)); + } +} diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index 9cbeaaed6..4eb882fdf 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -878,7 +878,7 @@ public String prepareHealthCheckRequest(String preparedMetrics) { return prepareCommonRequestData() + "&metrics=" + preparedMetrics; } - public String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType) { + public String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType, @Nullable String contentId) { JSONObject json = new JSONObject(); try { @@ -896,7 +896,13 @@ public String prepareFetchContents(int portraitWidth, int portraitHeight, int la L.e("Error while preparing fetch contents request"); } - return prepareCommonRequestData() + "&method=queue" + "&category=" + Arrays.asList(categories) + "&resolution=" + UtilsNetworking.urlEncodeString(json.toString()) + "&la=" + language + "&dt=" + deviceType; + String request = prepareCommonRequestData() + "&method=queue" + "&category=" + Arrays.asList(categories) + "&resolution=" + UtilsNetworking.urlEncodeString(json.toString()) + "&la=" + language + "&dt=" + deviceType; + + if (contentId != null) { + request += "&content_id=" + UtilsNetworking.urlEncodeString(contentId) + "&preview=true"; + } + + return request; } @Override diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 8bea804fb..e6f18f610 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -105,11 +105,11 @@ void onActivityStopped(int updatedActivityCount) { } } - void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure) { - L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "]"); + void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure, @Nullable String contentId) { + L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "], contentId: [" + contentId + "]"); DisplayMetrics displayMetrics = deviceInfo.mp.getDisplayMetrics(_cly.context_); - String requestData = prepareContentFetchRequest(displayMetrics, categories); + String requestData = prepareContentFetchRequest(displayMetrics, categories, contentId); ConnectionProcessor cp = requestQueueProvider.createConnectionProcessor(); final boolean networkingIsEnabled = cp.configProvider_.getNetworkingEnabled(); @@ -206,7 +206,7 @@ private void enterContentZoneInternal(@Nullable String[] categories, final int i return; } - fetchContentsInternal(validCategories, callbackOnFailure); + fetchContentsInternal(validCategories, callbackOnFailure, null); } }, L); } @@ -308,7 +308,7 @@ void notifyAfterContentIsClosed() { } @NonNull - private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics, @NonNull String[] categories) { + private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics, @NonNull String[] categories, @Nullable String contentId) { Resources resources = _cly.context_.getResources(); int currentOrientation = resources.getConfiguration().orientation; boolean portrait = currentOrientation == Configuration.ORIENTATION_PORTRAIT; @@ -353,7 +353,7 @@ private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics String language = Locale.getDefault().getLanguage().toLowerCase(); String deviceType = deviceInfo.mp.getDeviceType(_cly.context_); - return requestQueueProvider.prepareFetchContents(portraitWidth, portraitHeight, landscapeWidth, landscapeHeight, categories, language, deviceType); + return requestQueueProvider.prepareFetchContents(portraitWidth, portraitHeight, landscapeWidth, landscapeHeight, categories, language, deviceType, contentId); } boolean validateResponse(@NonNull JSONObject response) { @@ -472,6 +472,27 @@ private void exitContentZoneInternal() { pendingContentConfigs = null; } + void previewContentInternal(@NonNull String contentId) { + L.d("[ModuleContent] previewContentInternal, contentId: [" + contentId + "]"); + + if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { + L.w("[ModuleContent] previewContentInternal, Consent is not granted, skipping"); + return; + } + + if (deviceIdProvider.isTemporaryIdEnabled()) { + L.w("[ModuleContent] previewContentInternal, temporary device ID is enabled, skipping"); + return; + } + + if (isCurrentlyInContentZone) { + L.w("[ModuleContent] previewContentInternal, content is already being displayed, skipping"); + return; + } + + fetchContentsInternal(new String[] {}, null, contentId); + } + void refreshContentZoneInternal(boolean callRQFlush) { if (!configProvider.getRefreshContentZoneEnabled()) { return; @@ -528,6 +549,22 @@ public void exitContentZone() { exitContentZoneInternal(); } + /** + * Previews a specific content by its ID. + * This performs a one-time fetch for the given content + * without starting periodic content updates. + * + * @param contentId the ID of the content to preview + */ + public void previewContent(@Nullable String contentId) { + if (Utils.isNullOrEmpty(contentId)) { + L.w("[ModuleContent] previewContent, contentId is null or empty, skipping"); + return; + } + + previewContentInternal(contentId); + } + /** * Triggers a manual refresh of the content zone. * This method forces an update by fetching the latest content, diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java index d93d86a2e..1ad3627ca 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -74,7 +74,7 @@ interface RequestQueueProvider { String prepareHealthCheckRequest(String preparedMetrics); - String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType); + String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType, @Nullable String contentId); void registerInternalGlobalRequestCallbackAction(Runnable runnable); }