From 656727467a1472786a481bccd7fabcadf66eac3e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 14:31:48 +0300 Subject: [PATCH] feat: report app theme for UI --- CHANGELOG.md | 3 + .../android/sdk/ConnectionQueueTests.java | 15 +++ .../count/android/sdk/ModuleContentTests.java | 50 ++++++++ .../count/android/sdk/UtilsDeviceTests.java | 109 ++++++++++++++++++ .../ly/count/android/sdk/ModuleContent.java | 3 + .../ly/count/android/sdk/ModuleFeedback.java | 2 +- .../ly/count/android/sdk/ModuleRatings.java | 4 +- .../ly/count/android/sdk/UtilsDevice.java | 49 ++++++++ 8 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e34612c3..0c6321683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java index 2341079d8..e457593b9 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java @@ -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=")); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java index 7bc48adf4..fb4c3f80a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -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; @@ -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 { @@ -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 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); + } + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java new file mode 100644 index 000000000..5669bd21d --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java @@ -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))); + } +} 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 63f292b86..a0ad94692 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -404,6 +404,9 @@ Map 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; diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java index 11d40c8b1..ddc19bc9e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -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)) { diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java index a6a3b199a..eab9e96a1 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -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 + "]"); diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java index 794a4f19e..7bec45608 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java @@ -16,6 +16,7 @@ import android.view.WindowManager; import android.view.WindowMetrics; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; class UtilsDevice { @@ -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);