From db370fc0d5a1a7a2d8fc80eefde5a475ece53af2 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 13 Apr 2026 11:24:05 +0300 Subject: [PATCH 01/28] feat: update 24.7.0 changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98f1fe76..26351facb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -172,6 +172,8 @@ * During an internal timer tick * Upon flushing the event queue +* Updated the internal request mechanism. Downgrading from this version is not recommended. + * Added support for array, List and JSONArray to all user given segmentations. They will support only mutable and ummutable versions of the primitive types. Which are: * String, Integer, int, Boolean, bool, Float, float, Double, double, Long, long * Keep in mind that float array will be converted to the double array by the JSONArray From 7e1f7f21f89b0fb6df3318c0e05984f097c0a669 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 14 Apr 2026 15:27:28 +0300 Subject: [PATCH 02/28] feat: configuration cache compatible plugin --- .../android/plugins/uploadSymbols.groovy | 102 ++++++++++-------- 1 file changed, 57 insertions(+), 45 deletions(-) diff --git a/upload-plugin/src/main/groovy/ly/count/android/plugins/uploadSymbols.groovy b/upload-plugin/src/main/groovy/ly/count/android/plugins/uploadSymbols.groovy index 3e0cdb1b6..1c61b510e 100644 --- a/upload-plugin/src/main/groovy/ly/count/android/plugins/uploadSymbols.groovy +++ b/upload-plugin/src/main/groovy/ly/count/android/plugins/uploadSymbols.groovy @@ -4,7 +4,6 @@ import okhttp3.* import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.tasks.StopActionException -import org.gradle.api.tasks.StopExecutionException import static groovy.io.FileType.FILES @@ -20,29 +19,33 @@ class UploadSymbolsPluginExtension { class UploadSymbolsPlugin implements Plugin { void apply(Project project) { - OkHttpClient client = null - Request request = null def ext = project.extensions.create('countly', UploadSymbolsPluginExtension) project.tasks.register('uploadJavaSymbols') { group = "countly" description = "Upload Java minification mapping file mapping.txt to Countly server" - doFirst { - if (!ext.app_key) { - logger.error("Please specify your app key in countly block.") - throw new StopExecutionException("Please specify your app key in countly block.") - } - if (!ext.server) { - logger.error("Please specify your server in countly block.") - throw new StopExecutionException("Please specify your server in countly block.") - } - String buildVersion = project.android.defaultConfig.versionName - String url = ext.server; - String path = "i/crash_symbols/upload_symbol"; + + // Resolve project/extension values at configuration time to avoid + // capturing non-serializable Project reference in task actions + def buildVersion = project.android.defaultConfig.versionName + def appKey = ext.app_key + def serverUrl = ext.server + def noteJava = ext.noteJava + def mappingFilePath = "${project.buildDir}/${ext.mappingFile}" + + if (!appKey || !serverUrl) { + logger.warn("[Countly] uploadJavaSymbols: 'app_key' or 'server' is empty. " + + "Make sure the countly block is configured before this task is realized. " + + "Disabling task.") + enabled = false + } + + doLast { + String url = serverUrl + String path = "i/crash_symbols/upload_symbol" // Ensure there is exactly one "/" between the base URL and the path - url = url.endsWith("/") ? url + path : url + "/" + path; - def filePath = "$project.buildDir/$ext.mappingFile" - logger.debug("uploadJavaSymbols, Version name:[ {} ], Upload symbol url:[ {} ], Mapping file path:[ {} ]", buildVersion, url, filePath) - File file = new File(filePath) + url = url.endsWith("/") ? url + path : url + "/" + path + logger.debug("uploadJavaSymbols, Version name:[ {} ], Upload symbol url:[ {} ], Mapping file path:[ {} ]", buildVersion, url, mappingFilePath) + File file = new File(mappingFilePath) if (!file.exists()) { logger.error("Mapping file not found") throw new StopActionException("Mapping file not found") @@ -52,17 +55,11 @@ class UploadSymbolsPlugin implements Plugin { .addFormDataPart("symbols", file.getName(), RequestBody.create(MediaType.parse("text/plain"), file)) .addFormDataPart("platform", "android") - .addFormDataPart("app_key", ext.app_key) + .addFormDataPart("app_key", appKey) .addFormDataPart("build", buildVersion) - .addFormDataPart("note", ext.noteJava) + .addFormDataPart("note", noteJava) .build() - request = new Request.Builder().url(url).post(formBody).build() - } - doLast { - if (request == null) { - logger.error("Request not constructed") - throw new StopActionException("Something happened while constructing the request. Please try again.") - } + Request request = new Request.Builder().url(url).post(formBody).build() if (request.body() != null) { logger.debug("uploadJavaSymbols, Generated request: {}", request.body().toString()) @@ -70,7 +67,7 @@ class UploadSymbolsPlugin implements Plugin { logger.error("uploadJavaSymbols, Request body is null which should not be the case") } - client = new OkHttpClient() + OkHttpClient client = new OkHttpClient() Response response = client.newCall(request).execute() if (response.code() != 200) { @@ -88,28 +85,43 @@ class UploadSymbolsPlugin implements Plugin { project.tasks.register('uploadNativeSymbols') { group = "countly" description = "Upload breakpad symbols folder to Countly server" - doFirst { - String buildVersion = project.android.defaultConfig.versionName - String url = "${ext.server}/i/crash_symbols/upload_symbol" - String breakpadVersion = "$ext.dumpSymsPath/dump_syms --version".execute().getText().trim() + + // Resolve project/extension values at configuration time to avoid + // capturing non-serializable Project reference in task actions + def buildVersion = project.android.defaultConfig.versionName + def appKey = ext.app_key + def serverUrl = ext.server + def noteNative = ext.noteNative + def dumpSymsPath = ext.dumpSymsPath + def objectsDirPath = "${project.buildDir}/${ext.nativeObjectFilesDir}" + def countlyDirStr = "${project.buildDir}/intermediates/countly" + + if (!appKey || !serverUrl) { + logger.warn("[Countly] uploadNativeSymbols: 'app_key' or 'server' is empty. " + + "Make sure the countly block is configured before this task is realized. " + + "Disabling task.") + enabled = false + } + + doLast { + String url = "${serverUrl}/i/crash_symbols/upload_symbol" + String breakpadVersion = "$dumpSymsPath/dump_syms --version".execute().getText().trim() if (!(breakpadVersion =~ /^\d+\.\d+\+cly$/)) { breakpadVersion = "0.1+bpd" } - def objectsDir = new File("$project.buildDir/$ext.nativeObjectFilesDir") - def countlyDirStr = "$project.buildDir/intermediates/countly" - def countlyDir = new File("$countlyDirStr") + def objectsDir = new File(objectsDirPath) + def countlyDir = new File(countlyDirStr) logger.debug("uploadNativeSymbols, Version name:[ {} ], Upload symbol url:[ {} ], objectsDir:[ {} ], countlyDirStr:[ {} ], countlyDir:[ {} ], breakpadVersion:[ {} ]", buildVersion, url, objectsDir, countlyDirStr, countlyDir, breakpadVersion) countlyDir.deleteDir() countlyDir.mkdirs() - // println "objectsDir=$objectsDir" def filterObjectFiles = ~/.*\.so$/ def i = 0 def processFile = { i = i + 1 - def cmd = "$ext.dumpSymsPath/dump_syms $it" + def cmd = "$dumpSymsPath/dump_syms $it" println cmd def proc = cmd.execute() def outputStream = new StringBuffer() @@ -131,23 +143,23 @@ class UploadSymbolsPlugin implements Plugin { } objectsDir.traverse type: FILES, visit: processFile, nameFilter: filterObjectFiles def tarFileName = "$countlyDirStr/symbols.tar.gz" - project.ant.tar(destfile: tarFileName, basedir: "$countlyDirStr", includes: "symbols/**", compression: "gzip") + // Use standalone AntBuilder instead of project.ant for configuration cache compatibility + new groovy.ant.AntBuilder().tar(destfile: tarFileName, basedir: "$countlyDirStr", includes: "symbols/**", compression: "gzip") File file = new File(tarFileName) RequestBody formBody = new MultipartBody.Builder() .setType(MultipartBody.FORM) .addFormDataPart("symbols", file.getName(), RequestBody.create(MediaType.parse("text/plain"), file)) .addFormDataPart("platform", "android_native") - .addFormDataPart("app_key", ext.app_key) + .addFormDataPart("app_key", appKey) .addFormDataPart("build", buildVersion) - .addFormDataPart("note", ext.noteNative) + .addFormDataPart("note", noteNative) .addFormDataPart("sym_tool_ver", breakpadVersion) .build() - request = new Request.Builder().url(url).post(formBody).build() + Request request = new Request.Builder().url(url).post(formBody).build() logger.debug("uploadNativeSymbols, Generated request: {}", request.body().toString()) - } - doLast { - client = new OkHttpClient() + + OkHttpClient client = new OkHttpClient() Response response = client.newCall(request).execute() if (response.code() != 200) { From 3aed04deacd1ca1365028a69cd49213c19f4cee9 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 14 Apr 2026 15:28:56 +0300 Subject: [PATCH 03/28] feat: configuration cache compatible plugin: changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98f1fe76..ebe6a1f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Added gradle configuration cache support to upload symbols plugin. + ## 26.1.2 * Added `CountlyInitProvider` ContentProvider to register activity lifecycle callbacks before `Application.onCreate()`. This ensures the SDK captures the current activity in single-activity frameworks (Flutter, React Native) and apps with deferred initialization. * Added `CountlyConfig.setInitialActivity(Activity)` as an explicit way for wrapper SDKs to provide the host activity during initialization. From 7c6c86b5e9f87bbc71d1a354b9e1f41a3372b8ba Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 28 Apr 2026 16:10:04 +0300 Subject: [PATCH 04/28] feat: user props call trigger eq flush: changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f98f1fe76..ef2f11fcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Improved user properties auto-save conditions to flush event queue with every user property call. + ## 26.1.2 * Added `CountlyInitProvider` ContentProvider to register activity lifecycle callbacks before `Application.onCreate()`. This ensures the SDK captures the current activity in single-activity frameworks (Flutter, React Native) and apps with deferred initialization. * Added `CountlyConfig.setInitialActivity(Activity)` as an explicit way for wrapper SDKs to provide the host activity during initialization. From 7939fa6621a12b488637a77a76789a6e13ee9c12 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 28 Apr 2026 16:10:21 +0300 Subject: [PATCH 05/28] feat: user props call trigger eq flush: impl --- .../java/ly/count/android/sdk/ModuleUserProfile.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java index 15e8cf7c8..f2666a6ec 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java @@ -254,9 +254,7 @@ void modifyCustomData(String key, Object value, String mod) { } customMods.put(truncatedKey, ob); - applyUserPropertyCacheLimit(customMods); - - isSynced = false; + onUserPropertiesChanged(customMods); } catch (JSONException e) { e.printStackTrace(); } @@ -332,9 +330,15 @@ void setPropertiesInternal(@NonNull Map data) { } custom.putAll(dataCustomFields); - applyUserPropertyCacheLimit(custom); + onUserPropertiesChanged(custom); + } + private void onUserPropertiesChanged(Map sourceMap) { + applyUserPropertyCacheLimit(sourceMap); isSynced = false; + if (storageProvider.getEventQueueSize() > 0) { + saveInternal(); + } } private void applyUserPropertyCacheLimit(Map map) { From 2bbb2278453d5d1ec90c4fc7f0ca05ac9d49538f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 28 Apr 2026 17:14:20 +0300 Subject: [PATCH 06/28] feat: tests --- .../android/sdk/scUP_UserProfileTests.java | 33 ++++++++++--------- .../count/android/sdk/ModuleUserProfile.java | 2 +- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java index e3e87068f..4b81799ce 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/scUP_UserProfileTests.java @@ -53,7 +53,8 @@ public void eventSaveScenario_manualSessions() throws JSONException { countly.events().recordEvent("test_event3"); countly.userProfile().setProperty("theme", "light_mode"); - TestUtils.assertRQSize(3); // no request is generated on the way, not 2 anymore because of orientation event + // UPDATE: setProperty will trigger saving events + TestUtils.assertRQSize(4); // no request is generated on the way, not 2 anymore because of orientation event countly.sessions().endSession(); // begin_session + first user property request + 3 events + user property request with light_mode + end_session @@ -84,7 +85,7 @@ public void eventSaveScenario_onTimer() throws InterruptedException, JSONExcepti countly.events().recordEvent("test_event3"); countly.userProfile().setProperty("theme", "light_mode"); - TestUtils.assertRQSize(1); // no request is generated on the way + TestUtils.assertRQSize(2); // no request is generated on the way + UPDATE: user properties will trigger saving events Thread.sleep(3000); @@ -112,7 +113,8 @@ public void eventSaveScenario_changeDeviceIDWithoutMerge() throws JSONException countly.events().recordEvent("test_event3"); countly.userProfile().setProperty("theme", "light_mode"); - TestUtils.assertRQSize(1); // no request is generated on the way + // UPDATE: this will trigger saving events + TestUtils.assertRQSize(2); // no request is generated on the way countly.deviceId().changeWithoutMerge("new_device_id"); // this will begin a new session @@ -287,12 +289,12 @@ public void UP_203_CNR_A_events() throws JSONException { countly.events().recordEvent("A"); countly.events().recordEvent("B"); - sendSameData(countly); - countly.events().recordEvent("C"); - sendSameData(countly); - countly.events().recordEvent("D"); - sendSameData(countly); - countly.events().recordEvent("E"); + sendSameData(countly); // UPDATE: this will trigger A&B + countly.events().recordEvent("C"); // remaining UP + sendSameData(countly); // UPDATE: this will trigger C + countly.events().recordEvent("D"); // remaining UP + sendSameData(countly); // UPDATE: this will trigger D + countly.events().recordEvent("E"); // remaining UP TestUtils.assertRQSize(6); @@ -402,10 +404,10 @@ public void UP_207_CNR_M() throws JSONException { countly.sessions().beginSession(); countly.events().recordEvent("A"); countly.events().recordEvent("B"); - sendSameData(countly); + sendSameData(countly); // UPDATE: this will trigger A&B countly.sessions().endSession(); countly.events().recordEvent("C"); - sendUserData(countly); + sendUserData(countly); // UPDATE: this will trigger C countly.sessions().endSession(); countly.deviceId().changeWithMerge("merge_id"); sendSameData(countly); @@ -424,9 +426,9 @@ public void UP_207_CNR_M() throws JSONException { ModuleSessionsTests.validateSessionEndRequest(3, null, TestUtils.commonDeviceId); - TestUtils.validateRequest("merge_id", TestUtils.map("old_device_id", TestUtils.commonDeviceId), 4); + ModuleEventsTests.validateEventInRQ("C", 4, 0, 1); - ModuleEventsTests.validateEventInRQ("merge_id", "C", 5, 0, 1); + TestUtils.validateRequest("merge_id", TestUtils.map("old_device_id", TestUtils.commonDeviceId), 5); validateUserDataRequest(6, 8, "4", "merge_id"); @@ -477,9 +479,10 @@ public void UP_208_CR_CG_M() throws JSONException { ModuleUserProfileTests.validateUserProfileRequest(3, 9, TestUtils.map(), TestUtils.map("a12345", "4")); ModuleSessionsTests.validateSessionEndRequest(4, null, TestUtils.commonDeviceId); - TestUtils.validateRequest("merge_id", TestUtils.map("old_device_id", TestUtils.commonDeviceId), 5); - ModuleEventsTests.validateEventInRQ("merge_id", "C", 6, 0, 1); + ModuleEventsTests.validateEventInRQ("C", 5, 0, 1); + + TestUtils.validateRequest("merge_id", TestUtils.map("old_device_id", TestUtils.commonDeviceId), 6); validateUserDataRequest(7, 9, "4", "merge_id"); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java index f2666a6ec..e91dce3fb 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleUserProfile.java @@ -337,7 +337,7 @@ private void onUserPropertiesChanged(Map sourceMap) { applyUserPropertyCacheLimit(sourceMap); isSynced = false; if (storageProvider.getEventQueueSize() > 0) { - saveInternal(); + _cly.moduleRequestQueue.sendEventsIfNeeded(true); } } From 4ca6af15ab9e7b2c25d7fff119396cbe3ffabf67 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 28 Apr 2026 17:58:32 +0300 Subject: [PATCH 07/28] feat: tests --- .../androidTest/java/ly/count/android/sdk/DeviceIdTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/DeviceIdTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/DeviceIdTests.java index 9ddc2a852..1b8f7e8c1 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/DeviceIdTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/DeviceIdTests.java @@ -308,8 +308,8 @@ public void sessionDurationScenario_1() throws InterruptedException, JSONExcepti countly.deviceId().changeWithoutMerge("ff"); // this will generate a request with "end_session", "session_duration" fields and reset duration + begin_session assertEquals(6, TestUtils.getCurrentRQ().length); // not 5 anymore, it will send orientation event as well - TestUtils.validateRequest("ff_merge", TestUtils.map("old_device_id", "1234"), 1); - ModuleEventsTests.validateEventInRQ("ff_merge", "[CLY]_orientation", null, 1, 0.0d, 0.0d, "_CLY_", "_CLY_", "_CLY_", "_CLY_", 2, -1, 0, 1); + ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, "[CLY]_orientation", null, 1, 0.0d, 0.0d, "_CLY_", "_CLY_", "_CLY_", "_CLY_", 1, -1, 0, 1); + TestUtils.validateRequest("ff_merge", TestUtils.map("old_device_id", "1234"), 2); ModuleUserProfileTests.validateUserProfileRequest("ff_merge", 3, 6, TestUtils.map(), TestUtils.map("prop2", 123, "prop1", "string", "prop3", false)); ModuleSessionsTests.validateSessionEndRequest(4, 3, "ff_merge"); From 3da18e42b4c97ab2af4361a04706e8902bde79b1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 30 Apr 2026 17:43:04 +0300 Subject: [PATCH 08/28] feat: activity memory leak for contents: changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 118c24a9f..bef0c6fce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## XX.XX.XX * Improved user properties auto-save conditions to flush event queue with every user property call. +* Mitigated a memory leak where the content overlay retained the activity it was first opened in across subsequent activity transitions. + ## 26.1.2 * Added `CountlyInitProvider` ContentProvider to register activity lifecycle callbacks before `Application.onCreate()`. This ensures the SDK captures the current activity in single-activity frameworks (Flutter, React Native) and apps with deferred initialization. * Added `CountlyConfig.setInitialActivity(Activity)` as an explicit way for wrapper SDKs to provide the host activity during initialization. From 7ec039656bbd3e6b577f700d9260165e8844b78a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 30 Apr 2026 17:43:36 +0300 Subject: [PATCH 09/28] feat: activity memory leak for contents: test --- .../android/sdk/ContentOverlayViewTests.java | 44 +++++++++ .../count/android/sdk/ModuleContentTests.java | 97 +++++++++++++++++++ .../android/sdk/ModuleFeedbackTests.java | 87 +++++++++++++++++ 3 files changed, 228 insertions(+) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index 438ad7d5c..5d425cee5 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -790,4 +790,48 @@ public void contentUrlAction_noQueryParams_returnsFalse() { Assert.assertFalse(overlay.contentUrlAction(url, overlay.webView)); }); } + + // ===================== Memory leak prevention (issue #556) ===================== + + /** + * Structural invariant: the overlay's View.mContext must be the Application, not the + * constructing activity. This is what allows the overlay to outlive activity transitions + * without leaking the activity it was first opened in. + * + * Regression guard: if anyone changes the constructor's super(...) call back to the + * activity, this test will fail and surface the leak before users do. + */ + @Test + public void constructor_usesApplicationContext_notActivity() { + withActivity(activity -> { + overlay = createOverlay(activity); + Assert.assertNotSame( + "ContentOverlayView.mContext must not be the constructing Activity — " + + "View.mContext can never be swapped, so binding it to an Activity leaks " + + "that Activity for the lifetime of the overlay.", + activity, overlay.getContext()); + Assert.assertSame( + "ContentOverlayView.mContext must be the Application context.", + activity.getApplicationContext(), overlay.getContext()); + }); + } + + /** + * Same invariant for the embedded WebView. Even with the wrapper View using App context, + * a WebView constructed with Activity context would still pin the constructing activity + * via its own mContext. + */ + @Test + public void webView_usesApplicationContext_notActivity() { + withActivity(activity -> { + overlay = createOverlay(activity); + Assert.assertNotNull("WebView should be created during construction", overlay.webView); + Assert.assertNotSame( + "ContentOverlayView's WebView.mContext must not be the constructing Activity.", + activity, overlay.webView.getContext()); + Assert.assertSame( + "ContentOverlayView's WebView.mContext must be the Application context.", + activity.getApplicationContext(), overlay.webView.getContext()); + }); + } } 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 521a48aee..599d9008b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -1,5 +1,6 @@ package ly.count.android.sdk; +import android.app.Activity; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; import java.util.List; @@ -11,6 +12,8 @@ import org.junit.Test; import org.junit.runner.RunWith; +import static org.mockito.Mockito.mock; + @RunWith(AndroidJUnit4.class) public class ModuleContentTests { @@ -68,6 +71,12 @@ private void setIsCurrentlyInContentZone(ModuleContent module, boolean value) th field.set(module, value); } + private Activity getCurrentActivity(ModuleContent module) throws Exception { + java.lang.reflect.Field field = ModuleContent.class.getDeclaredField("currentActivity"); + field.setAccessible(true); + return (Activity) field.get(module); + } + // ======== previewContent public API tests ======== /** @@ -158,4 +167,92 @@ public void validateResponse() throws JSONException { valid.put("html", ""); Assert.assertTrue(mc.validateResponse(valid)); } + + // ======== Activity reference / leak prevention tests (issue #556) ======== + + /** + * onActivityDestroyed must null out currentActivity when the destroyed activity + * is the one currently tracked. This is the core leak fix. + */ + @Test + public void onActivityDestroyed_clearsCurrentActivity_whenIdentityMatches() throws Exception { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity act = mock(Activity.class); + mc.onActivityStarted(act, 1); + Assert.assertSame(act, getCurrentActivity(mc)); + + mc.onActivityDestroyed(act); + Assert.assertNull(getCurrentActivity(mc)); + } + + /** + * Destroying an activity other than the currently tracked one must NOT clear the field. + * This protects against losing the active activity reference when an old, already-replaced + * activity is finally destroyed. + */ + @Test + public void onActivityDestroyed_doesNotClear_whenDifferentActivity() throws Exception { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity tracked = mock(Activity.class); + Activity unrelated = mock(Activity.class); + mc.onActivityStarted(tracked, 1); + + mc.onActivityDestroyed(unrelated); + Assert.assertSame(tracked, getCurrentActivity(mc)); + } + + /** + * Rotation race regression: when onActivityStarted for the new activity fires before + * onActivityDestroyed for the old one, destroying the old activity must not wipe out + * the new tracked activity. + */ + @Test + public void onActivityDestroyed_doesNotClearNewerActivity_afterRotationRace() throws Exception { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity oldAct = mock(Activity.class); + Activity newAct = mock(Activity.class); + + mc.onActivityStarted(oldAct, 1); + mc.onActivityStarted(newAct, 2); + Assert.assertSame(newAct, getCurrentActivity(mc)); + + // Old activity is finally destroyed after the new one has already taken over. + mc.onActivityDestroyed(oldAct); + Assert.assertSame(newAct, getCurrentActivity(mc)); + } + + /** + * onActivityDestroyed must not throw when no activity has been tracked yet. + */ + @Test + public void onActivityDestroyed_isSafe_whenNoActivityTracked() throws Exception { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity stray = mock(Activity.class); + mc.onActivityDestroyed(stray); + Assert.assertNull(getCurrentActivity(mc)); + } + + /** + * The seeded activity path (onInitialActivitySeeded) must also be cleared on destroy. + */ + @Test + public void onActivityDestroyed_clearsSeededActivity() throws Exception { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity seeded = mock(Activity.class); + mc.onInitialActivitySeeded(seeded); + Assert.assertSame(seeded, getCurrentActivity(mc)); + + mc.onActivityDestroyed(seeded); + Assert.assertNull(getCurrentActivity(mc)); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java index 012aee87a..dcba891cf 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleFeedbackTests.java @@ -1,5 +1,6 @@ package ly.count.android.sdk; +import android.app.Activity; import androidx.annotation.NonNull; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -694,4 +695,90 @@ private void fillFeedbackWidgetSegmentationParams(Map segmentati segmentation.put("app_version", "1.0"); segmentation.put("widget_id", widgetId); } + + private Activity getCurrentActivity(ModuleFeedback module) throws Exception { + java.lang.reflect.Field field = ModuleFeedback.class.getDeclaredField("currentActivity"); + field.setAccessible(true); + return (Activity) field.get(module); + } + + // ======== Activity reference / leak prevention tests (issue #556) ======== + + /** + * onActivityDestroyed must null out currentActivity when the destroyed activity + * is the one currently tracked. This is the core leak fix. + */ + @Test + public void onActivityDestroyed_clearsCurrentActivity_whenIdentityMatches() throws Exception { + ModuleFeedback mf = mCountly.moduleFeedback; + + Activity act = mock(Activity.class); + mf.onActivityStarted(act, 1); + Assert.assertSame(act, getCurrentActivity(mf)); + + mf.onActivityDestroyed(act); + Assert.assertNull(getCurrentActivity(mf)); + } + + /** + * Destroying an activity other than the currently tracked one must NOT clear the field. + */ + @Test + public void onActivityDestroyed_doesNotClear_whenDifferentActivity() throws Exception { + ModuleFeedback mf = mCountly.moduleFeedback; + + Activity tracked = mock(Activity.class); + Activity unrelated = mock(Activity.class); + mf.onActivityStarted(tracked, 1); + + mf.onActivityDestroyed(unrelated); + Assert.assertSame(tracked, getCurrentActivity(mf)); + } + + /** + * Rotation race regression: when onActivityStarted for the new activity fires before + * onActivityDestroyed for the old one, destroying the old activity must not wipe out + * the new tracked activity. + */ + @Test + public void onActivityDestroyed_doesNotClearNewerActivity_afterRotationRace() throws Exception { + ModuleFeedback mf = mCountly.moduleFeedback; + + Activity oldAct = mock(Activity.class); + Activity newAct = mock(Activity.class); + + mf.onActivityStarted(oldAct, 1); + mf.onActivityStarted(newAct, 2); + Assert.assertSame(newAct, getCurrentActivity(mf)); + + mf.onActivityDestroyed(oldAct); + Assert.assertSame(newAct, getCurrentActivity(mf)); + } + + /** + * onActivityDestroyed must not throw when no activity has been tracked yet. + */ + @Test + public void onActivityDestroyed_isSafe_whenNoActivityTracked() throws Exception { + ModuleFeedback mf = mCountly.moduleFeedback; + + Activity stray = mock(Activity.class); + mf.onActivityDestroyed(stray); + Assert.assertNull(getCurrentActivity(mf)); + } + + /** + * The seeded activity path (onInitialActivitySeeded) must also be cleared on destroy. + */ + @Test + public void onActivityDestroyed_clearsSeededActivity() throws Exception { + ModuleFeedback mf = mCountly.moduleFeedback; + + Activity seeded = mock(Activity.class); + mf.onInitialActivitySeeded(seeded); + Assert.assertSame(seeded, getCurrentActivity(mf)); + + mf.onActivityDestroyed(seeded); + Assert.assertNull(getCurrentActivity(mf)); + } } From c1a52b21dd377033bc26676eb52e042f0ae3a57c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 30 Apr 2026 17:45:26 +0300 Subject: [PATCH 10/28] feat: activity destroyed callback accross modules --- sdk/src/main/java/ly/count/android/sdk/Countly.java | 6 +++--- .../main/java/ly/count/android/sdk/ModuleBase.java | 8 ++++++++ .../java/ly/count/android/sdk/ModuleContent.java | 12 ++++++++++++ .../java/ly/count/android/sdk/ModuleFeedback.java | 12 ++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index 07dd453e1..f9f4b4a65 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -783,9 +783,9 @@ public void onActivityDestroyed(Activity activity) { if (L.logEnabled()) { L.d("[Countly] onActivityDestroyed, " + activity.getClass().getSimpleName()); } - //for (ModuleBase module : modules) { - // module.callbackOnActivityDestroyed(activity); - //} + for (ModuleBase module : modules) { + module.onActivityDestroyed(activity); + } } }); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java b/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java index 4945d2840..c58265db0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleBase.java @@ -73,6 +73,14 @@ void onInitialActivitySeeded(@NonNull Activity activity) { void onActivityStopped(int updatedActivityCount) { } + /** + * Called when an Activity is destroyed. Modules that hold Activity references must + * clear them here (using identity comparison) to prevent leaking destroyed activities + * through the Countly singleton. + */ + void onActivityDestroyed(@NonNull Activity activity) { + } + //void callbackOnActivityCreated(Activity activity) { //} // 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 d9b7f730b..b525c28c4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -114,6 +114,18 @@ void onActivityStopped(int updatedActivityCount) { } } + @Override + void onActivityDestroyed(@NonNull Activity activity) { + // Identity check guards against clearing a newer activity when the destroy callback + // for an older activity arrives after onActivityStarted of the next one (rotation race). + if (currentActivity == activity) { + currentActivity = null; + } + // The overlay itself is intentionally kept alive across activity transitions. + // Its View context is the Application (not the constructing activity), so destroyed + // activities are not retained through the overlay. See ContentOverlayView constructor. + } + void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure, @Nullable String contentId) { L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "], contentId: [" + contentId + "]"); 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 0b3d40d8a..0c28d8e98 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -87,6 +87,18 @@ void onActivityStopped(int updatedActivityCount) { } } + @Override + void onActivityDestroyed(@NonNull Activity activity) { + // Identity check guards against clearing a newer activity when the destroy callback + // for an older activity arrives after onActivityStarted of the next one (rotation race). + if (currentActivity == activity) { + currentActivity = null; + } + // The overlay itself is intentionally kept alive across activity transitions. + // Its View context is the Application (not the constructing activity), so destroyed + // activities are not retained through the overlay. See ContentOverlayView constructor. + } + public interface RetrieveFeedbackWidgets { void onFinished(List retrievedWidgets, String error); } From 40309aacdfbae91b8cc637b0b5a4bed84154188e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 30 Apr 2026 17:56:59 +0300 Subject: [PATCH 11/28] feat: better activity transition --- .../count/android/sdk/ContentOverlayView.java | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index 18507be95..5cccba79c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -62,7 +62,10 @@ class ContentOverlayView extends FrameLayout { int orientation, @Nullable ContentCallback callback, @NonNull Runnable onClose) { - super(activity); + // Use Application context so View.mContext does not pin the constructing activity for + // the overlay's lifetime. The overlay is designed to outlive activity transitions; + // window attachment uses currentHostActivity (dynamically updated in attachToActivity). + super(activity.getApplicationContext()); this.configPortrait = portrait; this.configLandscape = landscape; @@ -122,9 +125,15 @@ public void onActivityStopped(@NonNull Activity a) { @Override public void onActivityDestroyed(@NonNull Activity a) { - if (a == currentHostActivity && isAddedToWindow) { - Log.d(Countly.TAG, "[ContentOverlayView] onActivityDestroyed, host activity destroyed, removing from window"); - removeFromWindow(); + if (a == currentHostActivity) { + if (isAddedToWindow) { + Log.d(Countly.TAG, "[ContentOverlayView] onActivityDestroyed, host activity destroyed, removing from window"); + removeFromWindow(); + } + // Drop the strong reference to the destroyed activity so it can be GC'd. + // The overlay is reattached via ModuleContent.onActivityStarted, which calls attachToActivity() + // and re-sets currentHostActivity for the next host. + currentHostActivity = null; } } }; @@ -621,6 +630,20 @@ private void eventAction(Map query) { } } + private void startActivityFromOverlay(@NonNull Intent intent) { + // Prefer the current host activity so the launched intent stays in the same task. + // Fall back to Application context with NEW_TASK (mContext is App since the overlay + // outlives activities), which is the only legal way to start an activity from a + // non-activity context. + Activity host = currentHostActivity; + if (host != null && !host.isFinishing() && !host.isDestroyed()) { + host.startActivity(intent); + } else { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + getContext().startActivity(intent); + } + } + private boolean linkAction(Map query, WebView view) { Log.i(Countly.TAG, "[ContentOverlayView] linkAction, link action detected"); if (!query.containsKey("link")) { @@ -632,7 +655,7 @@ private boolean linkAction(Map query, WebView view) { } Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link.toString())); - view.getContext().startActivity(intent); + startActivityFromOverlay(intent); return true; } @@ -900,7 +923,9 @@ private void cleanupWebView() { @SuppressLint("SetJavaScriptEnabled") private WebView createWebView(@NonNull Activity activity, @NonNull TransparentActivityConfig config) { - WebView wv = new CountlyWebView(activity); + // Application context: WebView's mContext must not retain the constructing activity, since the overlay + // (and its WebView) outlives activity transitions. Activity-specific operations route through currentHostActivity. + WebView wv = new CountlyWebView(activity.getApplicationContext()); wv.setVisibility(View.INVISIBLE); LayoutParams webLayoutParams = new LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); @@ -925,8 +950,7 @@ private WebView createWebView(@NonNull Activity activity, @NonNull TransparentAc if (url.endsWith("cly_x_int=1")) { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - getContext().startActivity(intent); + startActivityFromOverlay(intent); return true; } From bb26fd534006861ab7cae23823641fe103a1fe45 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 1 May 2026 17:01:52 +0300 Subject: [PATCH 12/28] feat: strict mode fix context usage violations --- CHANGELOG.md | 2 ++ .../count/android/sdk/ContentOverlayView.java | 30 +++++++++++++++++-- .../ly/count/android/sdk/UtilsDevice.java | 27 ++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 118c24a9f..99624468b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## XX.XX.XX * Improved user properties auto-save conditions to flush event queue with every user property call. +* Mitigated StrictMode `IncorrectContextUseViolation` warnings logged when the SDK retrieved device display metrics and constructed the content overlay view from a non-UI context. + ## 26.1.2 * Added `CountlyInitProvider` ContentProvider to register activity lifecycle callbacks before `Application.onCreate()`. This ensures the SDK captures the current activity in single-activity frameworks (Flutter, React Native) and apps with deferred initialization. * Added `CountlyConfig.setInitialActivity(Activity)` as an explicit way for wrapper SDKs to provide the host activity during initialization. diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index 18507be95..0a5ba7edc 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -56,13 +56,36 @@ class ContentOverlayView extends FrameLayout { private ComponentCallbacks orientationCallback; private Application.ActivityLifecycleCallbacks activityLifecycleCallbacks; + // Returns a Context suitable for constructing the overlay's Views without retaining + // a strong Java reference to the constructing Activity: + // - Pre-API 31: Application context (current behavior; no StrictMode UI-context check exists). + // - API 31+: createConfigurationContext from the Activity. The returned ContextImpl has + // mIsUiContext=true (inherited from Activity), satisfying detectIncorrectContextUse, + // but holds no Java reference back to the Activity — only an IBinder activity token, + // which does not pin the Activity for GC. + @NonNull + private static Context resolveOverlayContext(@NonNull Activity activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + return activity.createConfigurationContext(activity.getResources().getConfiguration()); + } catch (Throwable ignored) { + // Fall back to Application context if config-context creation fails. + } + } + return activity.getApplicationContext(); + } + @SuppressLint("SetJavaScriptEnabled") ContentOverlayView(@NonNull Activity activity, @NonNull TransparentActivityConfig portrait, @NonNull TransparentActivityConfig landscape, int orientation, @Nullable ContentCallback callback, @NonNull Runnable onClose) { - super(activity); + // View.mContext must not pin the constructing activity (overlay outlives activity + // transitions; window attachment uses currentHostActivity). On API 31+ we additionally + // need a UI context to satisfy StrictMode#detectIncorrectContextUse — see + // resolveOverlayContext above. + super(resolveOverlayContext(activity)); this.configPortrait = portrait; this.configLandscape = landscape; @@ -900,7 +923,10 @@ private void cleanupWebView() { @SuppressLint("SetJavaScriptEnabled") private WebView createWebView(@NonNull Activity activity, @NonNull TransparentActivityConfig config) { - WebView wv = new CountlyWebView(activity); + // WebView's mContext must not retain the constructing activity, since the overlay + // (and its WebView) outlives activity transitions. Activity-specific operations route + // through currentHostActivity. See resolveOverlayContext for the API 31+ UI-context handling. + WebView wv = new CountlyWebView(resolveOverlayContext(activity)); wv.setVisibility(View.INVISIBLE); LayoutParams webLayoutParams = new LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); 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 cc155d0ac..794a4f19e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java @@ -26,7 +26,7 @@ private UtilsDevice() { @NonNull static DisplayMetrics getDisplayMetrics(@NonNull final Context context) { - final WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + final WindowManager wm = obtainWindowManager(context); final DisplayMetrics metrics = new DisplayMetrics(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { @@ -37,6 +37,31 @@ static DisplayMetrics getDisplayMetrics(@NonNull final Context context) { return metrics; } + // On API 31+, getSystemService(WINDOW_SERVICE) from a non-UI context trips + // StrictMode#detectIncorrectContextUse. Prefer a UI context when one is + // available (held foreground Activity, then createWindowContext fallback) + // and only resolve WindowManager from it. + @NonNull + private static WindowManager obtainWindowManager(@NonNull Context context) { + if (context instanceof Activity) { + return (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Activity held = CountlyActivityHolder.getInstance().getActivity(); + if (held != null) { + return (WindowManager) held.getSystemService(Context.WINDOW_SERVICE); + } + try { + Context uiContext = context.createWindowContext( + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, null); + return (WindowManager) uiContext.getSystemService(Context.WINDOW_SERVICE); + } catch (Throwable ignored) { + // Fall through to original context if window context creation is rejected. + } + } + return (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + @TargetApi(Build.VERSION_CODES.R) private static void applyWindowMetrics(@NonNull Context context, @NonNull WindowManager wm, From b1768d6cf9da03b3b57aa295b15b2078c066db5f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 1 May 2026 17:56:53 +0300 Subject: [PATCH 13/28] fix: direct key events to background view for contents --- CHANGELOG.md | 3 + .../count/android/sdk/ContentOverlayView.java | 76 ++++++++++++++++++- .../ly/count/android/sdk/CountlyWebView.java | 10 +++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 118c24a9f..784687c45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## XX.XX.XX * Improved user properties auto-save conditions to flush event queue with every user property call. +* Mitigated an issue where content overlays and feedback widgets prevented keyboard input on the underlying activity's text fields while displayed. +* Mitigated a memory retention issue where content overlays and feedback widgets could be briefly held in memory after closing, surfacing under repeated open/close cycles. + ## 26.1.2 * Added `CountlyInitProvider` ContentProvider to register activity lifecycle callbacks before `Application.onCreate()`. This ensures the SDK captures the current activity in single-activity frameworks (Flutter, React Native) and apps with deferred initialization. * Added `CountlyConfig.setInitialActivity(Activity)` as an explicit way for wrapper SDKs to provide the host activity during initialization. diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index 18507be95..e881ec2ff 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -15,6 +15,7 @@ import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; @@ -187,6 +188,50 @@ public boolean dispatchKeyEvent(KeyEvent event) { return super.dispatchKeyEvent(event); } + @Override + public boolean dispatchTouchEvent(MotionEvent ev) { + // Dynamically transfer IME focus between the overlay and the host activity: + // - ACTION_OUTSIDE (delivered because of FLAG_WATCH_OUTSIDE_TOUCH): user + // touched outside the overlay's bounds; the touch already passed through + // to the activity via FLAG_NOT_TOUCH_MODAL, but we additionally restore + // FLAG_NOT_FOCUSABLE so the activity's EditText can claim IME focus. + // - ACTION_DOWN (touch inside the overlay's bounds): clear FLAG_NOT_FOCUSABLE + // so the WebView's form fields can bring up the keyboard. + // Consumes nothing; existing touch handling (WebView, etc.) continues normally. + if (ev.getAction() == MotionEvent.ACTION_OUTSIDE) { + setWindowFocusable(false); + return true; + } + if (ev.getAction() == MotionEvent.ACTION_DOWN) { + setWindowFocusable(true); + } + return super.dispatchTouchEvent(ev); + } + + private void setWindowFocusable(boolean focusable) { + if (!isAddedToWindow || windowManager == null) { + return; + } + try { + WindowManager.LayoutParams lp = (WindowManager.LayoutParams) getLayoutParams(); + if (lp == null) { + return; + } + boolean alreadyFocusable = (lp.flags & WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) == 0; + if (alreadyFocusable == focusable) { + return; + } + if (focusable) { + lp.flags &= ~WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + } else { + lp.flags |= WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; + } + windowManager.updateViewLayout(this, lp); + } catch (Exception e) { + Log.w(Countly.TAG, "[ContentOverlayView] setWindowFocusable, failed to update flags", e); + } + } + private TransparentActivityConfig getCurrentConfig() { if (currentOrientation == Configuration.ORIENTATION_LANDSCAPE) { return configLandscape; @@ -210,9 +255,19 @@ private WindowManager.LayoutParams createWindowParams(@NonNull Activity activity } } + // FLAG_NOT_FOCUSABLE: overlay does NOT grab IME focus by default, so the + // underlying Activity's EditText can receive keyboard input even while the + // overlay is shown. Toggled off in dispatchTouchEvent on inside touches so + // the WebView's /