Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## XX.XX.XX
* Added support for reporting the app's current theme (light or dark) when presenting feedback widgets, rating widgets, and content, so they are displayed in matching conditions.

## 26.1.4
* ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,19 @@ public void testPrepareCommonRequest() {
}
}
}

/**
* The theme ("th") parameter is reported on the URLs loaded into the WebView (feedback/rating
* widget and content URLs), not on the feedback-list or content-fetch data requests. These
* requests must therefore never carry "th" regardless of the device theme. The actual "th"
* append logic is validated in UtilsDeviceTests, its wiring into content in ModuleContentTests.
*/
@Test
public void testThemeParam_notOnFeedbackListNorFetchContents() {
final String feedbackRequest = connQ.prepareFeedbackListRequest();
final String contentRequest = connQ.prepareFetchContents(100, 200, 200, 100, new String[] {}, "en", "mobile", null);

Assert.assertFalse(feedbackRequest.contains("th="));
Assert.assertFalse(contentRequest.contains("th="));
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package ly.count.android.sdk;

import android.app.Activity;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.util.DisplayMetrics;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.After;
Expand All @@ -13,6 +17,7 @@
import org.junit.runner.RunWith;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@RunWith(AndroidJUnit4.class)
public class ModuleContentTests {
Expand Down Expand Up @@ -375,4 +380,49 @@ public void contentZone_doesNotResumeAfterExplicitExit() throws Exception {
mCountly.deviceId().changeWithoutMerge("real_user_after_exit");
Assert.assertFalse(readShouldFetchContents(mCountly.moduleContent));
}

// ======== theme ("th") param on the content URL ========

/**
* parseContent must append the app theme ("th") to the content URL that gets loaded into the
* WebView, so the content is rendered matching the theme. A dark foreground Activity is set so
* the resolved theme is deterministic ("d"); the URL already has a query, so "&th=d" is used.
* The l/d mapping and separator logic themselves are covered by UtilsDeviceTests.
*/
@Test
public void parseContent_appendsThemeParamToContentUrl() throws JSONException {
Countly countly = initWithConsent(true);
ModuleContent mc = countly.moduleContent;

Activity darkActivity = mock(Activity.class);
Resources darkResources = mock(Resources.class);
Configuration darkCfg = new Configuration();
darkCfg.uiMode = Configuration.UI_MODE_NIGHT_YES;
when(darkActivity.getResources()).thenReturn(darkResources);
when(darkResources.getConfiguration()).thenReturn(darkCfg);
CountlyActivityHolder.getInstance().setActivity(darkActivity);

try {
String html = "https://content.example/page?cid=1";
JSONObject placement = new JSONObject();
placement.put("x", 0);
placement.put("y", 0);
placement.put("w", 100);
placement.put("h", 100);
JSONObject geo = new JSONObject();
geo.put("p", placement);
JSONObject response = new JSONObject();
response.put("html", html);
response.put("geo", geo);

DisplayMetrics dm = TestUtils.getContext().getResources().getDisplayMetrics();
Map<Integer, TransparentActivityConfig> configs = mc.parseContent(response, dm);

TransparentActivityConfig portrait = configs.get(Configuration.ORIENTATION_PORTRAIT);
Assert.assertNotNull(portrait);
Assert.assertEquals(html + "&th=d", portrait.url);
} finally {
CountlyActivityHolder.getInstance().clearActivity(darkActivity);
}
}
}
109 changes: 109 additions & 0 deletions sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package ly.count.android.sdk;

import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

@RunWith(AndroidJUnit4.class)
public class UtilsDeviceTests {

/**
* getThemeMode and appendThemeParam prefer the foreground Activity's configuration. These
* tests exercise the fallback-context path, so make sure no Activity is registered from a
* previously run test in the same process.
*/
@Before
public void setUp() {
clearForegroundActivity();
}

@After
public void tearDown() {
clearForegroundActivity();
}

private void clearForegroundActivity() {
Activity current = CountlyActivityHolder.getInstance().getActivity();
if (current != null) {
CountlyActivityHolder.getInstance().clearActivity(current);
}
}

/**
* Builds a context whose resources report exactly the given UI_MODE_NIGHT_* flag. A mock is
* used deliberately: a real createConfigurationContext falls back to the device's night mode
* for the UNDEFINED case, so it can not report "undefined" independently of the test device.
*/
private Context contextWithNightMode(int nightModeFlag) {
Context ctx = mock(Context.class);
Resources res = mock(Resources.class);
Configuration cfg = new Configuration();
cfg.uiMode = nightModeFlag;
when(ctx.getResources()).thenReturn(res);
when(res.getConfiguration()).thenReturn(cfg);
return ctx;
}

// ======== getThemeMode ========

/** A dark-configured context resolves to "d", a light one to "l", undefined to null. */
@Test
public void getThemeMode_mapsNightModeFlags() {
Assert.assertEquals("d", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_YES)));
Assert.assertEquals("l", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_NO)));
Assert.assertNull(UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_UNDEFINED)));
}

/** The foreground Activity's configuration wins over the fallback context. */
@Test
public void getThemeMode_prefersForegroundActivity() {
Activity darkActivity = mock(Activity.class);
Resources darkResources = mock(Resources.class);
Configuration darkCfg = new Configuration();
darkCfg.uiMode = Configuration.UI_MODE_NIGHT_YES;
when(darkActivity.getResources()).thenReturn(darkResources);
when(darkResources.getConfiguration()).thenReturn(darkCfg);

CountlyActivityHolder.getInstance().setActivity(darkActivity);
try {
// fallback is light, but the dark Activity must take precedence
Assert.assertEquals("d", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_NO)));
} finally {
CountlyActivityHolder.getInstance().clearActivity(darkActivity);
}
}

// ======== appendThemeParam ========

/** With an existing query string the theme is appended with "&". */
@Test
public void appendThemeParam_appendsWithAmpersandWhenQueryPresent() {
String url = "https://widgets.example/feedback/nps?widget_id=abc&app_key=k";
Assert.assertEquals(url + "&th=d", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_YES)));
Assert.assertEquals(url + "&th=l", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_NO)));
}

/** Without a query string the theme is appended with "?". */
@Test
public void appendThemeParam_appendsWithQuestionMarkWhenNoQuery() {
String url = "https://content.example/page";
Assert.assertEquals(url + "?th=l", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_NO)));
}

/** When the theme is undefined the URL is returned untouched. */
@Test
public void appendThemeParam_returnsUrlUnchangedWhenThemeUndefined() {
String url = "https://content.example/page?a=1";
Assert.assertEquals(url, UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_UNDEFINED)));
}
}
3 changes: 3 additions & 0 deletions sdk/src/main/java/ly/count/android/sdk/ModuleContent.java
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,9 @@ Map<Integer, TransparentActivityConfig> parseContent(@NonNull JSONObject respons
assert response != null;

String content = response.optString("html");
if (!content.isEmpty()) {
content = UtilsDevice.appendThemeParam(content, _cly.context_);
}
JSONObject coordinates = response.optJSONObject("geo");

assert coordinates != null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ void presentFeedbackWidgetInternal(@Nullable final CountlyFeedbackWidget widgetI
widgetListUrl.append("&custom=");
widgetListUrl.append(customObjectToSendWithTheWidget);

String preparedWidgetUrl = widgetListUrl.toString();
String preparedWidgetUrl = UtilsDevice.appendThemeParam(widgetListUrl.toString(), context);
L.d("[ModuleFeedback] Using following url for widget:[" + preparedWidgetUrl + "]");

if (!Utils.isNullOrEmpty(widgetInfo.widgetVersion)) {
Expand Down
4 changes: 2 additions & 2 deletions sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java
Original file line number Diff line number Diff line change
Expand Up @@ -490,9 +490,9 @@ synchronized void showFeedbackPopupInternal(@Nullable final String widgetId, @Nu
}

String requestData = requestQueueProvider.prepareRatingWidgetRequest(widgetId);
final String ratingWidgetUrl = baseInfoProvider.getServerURL() + "/feedback?widget_id=" + widgetId +
final String ratingWidgetUrl = UtilsDevice.appendThemeParam(baseInfoProvider.getServerURL() + "/feedback?widget_id=" + widgetId +
"&device_id=" + UtilsNetworking.urlEncodeString(deviceIdProvider.getDeviceId()) +
"&app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey());
"&app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey()), activity);

L.d("[ModuleRatings] rating widget url :[" + ratingWidgetUrl + "]");

Expand Down
49 changes: 49 additions & 0 deletions sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import android.view.WindowManager;
import android.view.WindowMetrics;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

class UtilsDevice {

Expand All @@ -24,6 +25,54 @@ class UtilsDevice {
private UtilsDevice() {
}

/**
* Resolves the theme (dark/light) the app is currently rendering with. Reads from the current
* foreground Activity when available and falls back to the given context otherwise. The Activity
* is preferred because per-app night-mode overrides (e.g. AppCompatDelegate.setDefaultNightMode)
* are applied to the Activity's resources, not the application context, and because in-app
* messages render in the Activity's window - so its configuration is the effective theme.
*
* @param fallbackContext context used when no foreground Activity is available
* @return "d" for dark mode, "l" for light mode, or null when the mode is undefined/unavailable
*/
@Nullable
static String getThemeMode(@NonNull final Context fallbackContext) {
try {
final Activity activity = CountlyActivityHolder.getInstance().getActivity();
final Context context = activity != null ? activity : fallbackContext;
int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
switch (nightModeFlags) {
case Configuration.UI_MODE_NIGHT_YES:
return "d";
case Configuration.UI_MODE_NIGHT_NO:
return "l";
default:
return null;
}
} catch (Throwable t) {
return null;
}
}

/**
* Appends the app's current theme as the "th" query parameter (l = light, d = dark) to the
* given URL, so a feedback widget or content loaded in a WebView is rendered matching the
* theme the app is displaying with. Uses "?" as the separator when the URL has no query yet,
* "&" otherwise. When the theme can not be resolved the URL is returned unchanged.
*
* @param url URL that will be loaded in a WebView
* @param context context used to resolve the theme when no foreground Activity is available
* @return the URL with "th" appended, or the original URL when the theme is undefined
*/
@NonNull
static String appendThemeParam(@NonNull final String url, @NonNull final Context context) {
final String theme = getThemeMode(context);
if (theme == null) {
return url;
}
return url + (url.contains("?") ? "&" : "?") + "th=" + theme;
}

@NonNull
static DisplayMetrics getDisplayMetrics(@NonNull final Context context) {
final WindowManager wm = obtainWindowManager(context);
Expand Down
Loading