Skip to content

Commit ce6f769

Browse files
Merge pull request #562 from Countly/staging01052026
Mixed: Content activity lifecycle fixes
2 parents 118010c + 34dd8b9 commit ce6f769

10 files changed

Lines changed: 338 additions & 85 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,7 @@ upload-plugin/build/pluginUnderTestMetadata/plugin-under-test-metadata.propertie
3838
upload-plugin/build/
3939
app-native/.cxx/
4040
.vscode/
41+
42+
# Content/feedback UI test runner artifacts (videos, logcat, verdicts)
43+
.github/scripts/test_output/
44+
.github/scripts/__pycache__/

CHANGELOG.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
* Added gradle configuration cache support to upload symbols plugin.
33
* Improved user properties auto-save conditions to flush event queue with every user property call.
44

5-
* Mitigated StrictMode `IncorrectContextUseViolation` warnings logged when the SDK retrieved device display metrics and constructed the content overlay view from a non-UI context.
6-
* Mitigated an issue where content overlays and feedback widgets prevented keyboard input on the underlying activity's text fields while displayed.
7-
* 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.
8-
* Mitigated a memory leak where the content overlay retained the activity it was first opened in across subsequent activity transitions.
5+
* Mitigated `IncorrectContextUseViolation` StrictMode warnings from non-UI context use in display metrics and content overlay construction.
6+
* Mitigated an issue where content overlays and feedback widgets blocked keyboard input on the host activity.
7+
* Mitigated a memory retention issue where content overlays and feedback widgets were briefly held after closing.
8+
* Mitigated a memory leak where the content overlay retained its initial host activity across activity transitions.
9+
* Mitigated a memory leak where content overlays and feedback widgets remained referenced via lifecycle callbacks when the host activity finished.
910

1011
## 26.1.2
1112
* 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.

app/build.gradle

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,24 @@ android {
5252
}
5353
}
5454

55+
buildFeatures {
56+
// Required so `BuildConfig.COUNTLY_SERVER_URL` / `COUNTLY_APP_KEY`
57+
// (declared below) get generated. AGP 8+ disables BuildConfig by default.
58+
buildConfig true
59+
}
60+
5561
defaultConfig {
5662
applicationId "ly.count.android.demo"
5763
minSdk 21
5864
targetSdk 35
5965
versionCode 1
6066
versionName "1.0"
6167
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
68+
69+
buildConfigField "String", "COUNTLY_SERVER_URL",
70+
"\"${System.getenv('COUNTLY_SERVER_URL') ?: project.findProperty('countlyServerUrl') ?: 'https://your.server.ly'}\""
71+
buildConfigField "String", "COUNTLY_APP_KEY",
72+
"\"${System.getenv('COUNTLY_APP_KEY') ?: project.findProperty('countlyAppKey') ?: 'YOUR_APP_KEY'}\""
6273
}
6374
buildTypes {
6475
debug {

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,14 @@
9999
<activity
100100
android:name=".ActivityExampleFeedback"
101101
android:label="@string/activity_name_feedback"
102-
android:configChanges="orientation|screenSize"/>
102+
android:configChanges="orientation|screenSize"
103+
android:exported="true"/>
103104

104105
<activity
105106
android:name=".ActivityExampleContentZone"
106107
android:label="@string/activity_name_content_zone"
107-
android:configChanges="orientation|screenSize"/>
108+
android:configChanges="orientation|screenSize"
109+
android:exported="true"/>
108110

109111
<activity
110112
android:name=".ActivityExampleFragments"

app/src/main/java/ly/count/android/demo/App.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import android.os.Bundle;
1515
import android.os.StrictMode;
1616
import android.util.Log;
17+
import android.webkit.WebView;
1718
import androidx.annotation.NonNull;
1819
import androidx.core.content.ContextCompat;
1920
import com.google.android.gms.tasks.OnCompleteListener;
@@ -35,9 +36,9 @@
3536
import static ly.count.android.sdk.messaging.CountlyPush.COUNTLY_BROADCAST_PERMISSION_POSTFIX;
3637

3738
public class App extends Application {
38-
/** You should use try.count.ly instead of YOUR_SERVER for the line below if you are using Countly trial service */
39-
private final static String COUNTLY_SERVER_URL = "https://your.server.ly";
40-
private final static String COUNTLY_APP_KEY = "YOUR_APP_KEY";
39+
40+
private static String COUNTLY_SERVER_URL = "https://your.server.ly";
41+
private static String COUNTLY_APP_KEY = "YOUR_APP_KEY";
4142
private final static String DEFAULT_URL = "https://your.server.ly";
4243
private final static String DEFAULT_APP_KEY = "YOUR_APP_KEY";
4344

@@ -47,6 +48,24 @@ public class App extends Application {
4748
public void onCreate() {
4849
super.onCreate();
4950

51+
// Enable WebView remote debugging so the test runner can attach to the
52+
// content/feedback widget's DOM via Chrome DevTools Protocol over an
53+
// adb-forwarded socket. Process-wide flag — affects every WebView in
54+
// this process, including the SDK's overlay WebView. Debug-only;
55+
// BuildConfig.DEBUG is true on debug builds, false on release.
56+
if (BuildConfig.DEBUG) {
57+
WebView.setWebContentsDebuggingEnabled(true);
58+
}
59+
60+
COUNTLY_SERVER_URL =
61+
DEFAULT_URL.equals(BuildConfig.COUNTLY_SERVER_URL)
62+
? DEFAULT_URL
63+
: BuildConfig.COUNTLY_SERVER_URL;
64+
COUNTLY_APP_KEY =
65+
DEFAULT_APP_KEY.equals(BuildConfig.COUNTLY_APP_KEY)
66+
? DEFAULT_APP_KEY
67+
: BuildConfig.COUNTLY_APP_KEY;
68+
5069
if (DEFAULT_URL.equals(COUNTLY_SERVER_URL) || DEFAULT_APP_KEY.equals(COUNTLY_APP_KEY)) {
5170
Log.e("CountlyDemo", "Please provide correct COUNTLY_SERVER_URL and COUNTLY_APP_KEY");
5271
return;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package ly.count.android.demo
2+
3+
import android.os.Build
4+
import android.os.StrictMode
5+
import android.os.StrictMode.ThreadPolicy.Builder
6+
import android.os.StrictMode.VmPolicy
7+
import android.os.strictmode.UntaggedSocketViolation
8+
import android.os.strictmode.Violation
9+
import android.util.Log
10+
import java.util.concurrent.Executors
11+
12+
object StrictModeConfigurator {
13+
14+
private val penaltyExecutor by lazy { Executors.newSingleThreadExecutor() }
15+
16+
private val threadPolicy: StrictMode.ThreadPolicy
17+
get() = Builder()
18+
.detectAll()
19+
.apply {
20+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
21+
penaltyListener(penaltyExecutor) { violation ->
22+
val knownIssue = knownThreadViolations.any { it(violation) }
23+
if (!knownIssue) Log.w("StrictMode", null, violation)
24+
}
25+
} else {
26+
penaltyLog()
27+
}
28+
}
29+
.penaltyDeathOnNetwork()
30+
.build()
31+
32+
private val knownThreadViolations: List<Violation.() -> Boolean> by lazy {
33+
listOf(
34+
// add known violations if any
35+
)
36+
}
37+
38+
private val vmPolicy: VmPolicy
39+
get() = VmPolicy.Builder()
40+
.apply {
41+
detectActivityLeaks()
42+
detectLeakedSqlLiteObjects()
43+
detectLeakedClosableObjects()
44+
detectLeakedRegistrationObjects()
45+
detectFileUriExposure()
46+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
47+
detectCleartextNetwork()
48+
}
49+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
50+
detectContentUriWithoutPermission()
51+
detectUntaggedSockets() // okhttp "issue"
52+
}
53+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
54+
detectCredentialProtectedWhileLocked()
55+
}
56+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
57+
detectIncorrectContextUse() // countly has known issue
58+
detectUnsafeIntentLaunch()
59+
}
60+
}
61+
.apply {
62+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
63+
penaltyListener(penaltyExecutor) { violation ->
64+
val knownIssue = knownVmViolations.any { it(violation) }
65+
if (!knownIssue) Log.w("StrictMode", null, violation)
66+
}
67+
} else {
68+
penaltyLog()
69+
}
70+
}
71+
.penaltyDeathOnFileUriExposure()
72+
.build()
73+
74+
private val knownVmViolations: List<Violation.() -> Boolean> by lazy {
75+
listOfNotNull(
76+
{
77+
this is UntaggedSocketViolation && stackTrace.any {
78+
it.className.contains("ImmediateRequestMaker") || it.className.contains("ConnectionProcessor") // countly
79+
}
80+
},
81+
)
82+
}
83+
84+
@JvmStatic
85+
fun configure() {
86+
StrictMode.setThreadPolicy(threadPolicy)
87+
StrictMode.setVmPolicy(vmPolicy)
88+
}
89+
}

sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,19 @@ public void createWindowParams_correctTypeAndFlags() {
539539
Assert.assertEquals("Type should be TYPE_APPLICATION",
540540
WindowManager.LayoutParams.TYPE_APPLICATION, params.type);
541541

542+
// Expected base flags match the production set in createWindowParams:
543+
// FLAG_NOT_FOCUSABLE + FLAG_WATCH_OUTSIDE_TOUCH let the host
544+
// activity keep IME focus while still receiving outside-touch
545+
// events the overlay routes back via dispatchTouchEvent.
546+
// FLAG_NOT_TOUCHABLE is added only while content is still loading
547+
// (gates touches until the WebView is visible). The test
548+
// constructs the overlay with about:blank and never waits for
549+
// afterPageFinished, so isContentLoaded stays false here.
542550
int expectedFlags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
543551
| WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
544552
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
553+
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
554+
| WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
545555
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
546556
Assert.assertEquals("Flags should match", expectedFlags, params.flags);
547557

@@ -794,12 +804,21 @@ public void contentUrlAction_noQueryParams_returnsFalse() {
794804
// ===================== Memory leak prevention (issue #556) =====================
795805

796806
/**
797-
* Structural invariant: the overlay's View.mContext must be the Application, not the
798-
* constructing activity. This is what allows the overlay to outlive activity transitions
799-
* without leaking the activity it was first opened in.
807+
* Structural invariant: the overlay's View.mContext must not pin the
808+
* constructing Activity. The overlay outlives activity transitions, and
809+
* View.mContext can never be swapped after construction — if it's the
810+
* Activity, that Activity stays GC-pinned for the overlay's full lifetime.
800811
*
801-
* Regression guard: if anyone changes the constructor's super(...) call back to the
802-
* activity, this test will fail and surface the leak before users do.
812+
* The exact context type is API-dependent (see ContentOverlayView#resolveOverlayContext):
813+
* - Pre-API 31: Application context.
814+
* - API 31+: createConfigurationContext from the Activity — a ContextImpl
815+
* wrapper that holds an IBinder token, not the Activity instance, so
816+
* GC isn't blocked. Required for StrictMode#detectIncorrectContextUse.
817+
*
818+
* In both cases, getApplicationContext() resolves to the same Application.
819+
* The test asserts both that the context is NOT the Activity and that it
820+
* routes back to the right Application — which is the actual leak-avoidance
821+
* contract independent of API level.
803822
*/
804823
@Test
805824
public void constructor_usesApplicationContext_notActivity() {
@@ -811,15 +830,19 @@ public void constructor_usesApplicationContext_notActivity() {
811830
+ "that Activity for the lifetime of the overlay.",
812831
activity, overlay.getContext());
813832
Assert.assertSame(
814-
"ContentOverlayView.mContext must be the Application context.",
815-
activity.getApplicationContext(), overlay.getContext());
833+
"ContentOverlayView.mContext must resolve to the same Application as the "
834+
+ "constructing Activity (Application directly on <API 31, "
835+
+ "ConfigurationContext-of-Activity on API 31+).",
836+
activity.getApplicationContext(),
837+
overlay.getContext().getApplicationContext());
816838
});
817839
}
818840

819841
/**
820-
* Same invariant for the embedded WebView. Even with the wrapper View using App context,
821-
* a WebView constructed with Activity context would still pin the constructing activity
822-
* via its own mContext.
842+
* Same invariant for the embedded WebView. Even with the wrapper View not
843+
* pinning the Activity, a WebView constructed with the Activity directly
844+
* would still pin it via its own mContext. See
845+
* constructor_usesApplicationContext_notActivity for the API-level rationale.
823846
*/
824847
@Test
825848
public void webView_usesApplicationContext_notActivity() {
@@ -830,8 +853,10 @@ public void webView_usesApplicationContext_notActivity() {
830853
"ContentOverlayView's WebView.mContext must not be the constructing Activity.",
831854
activity, overlay.webView.getContext());
832855
Assert.assertSame(
833-
"ContentOverlayView's WebView.mContext must be the Application context.",
834-
activity.getApplicationContext(), overlay.webView.getContext());
856+
"ContentOverlayView's WebView.mContext must resolve to the same Application "
857+
+ "as the constructing Activity.",
858+
activity.getApplicationContext(),
859+
overlay.webView.getContext().getApplicationContext());
835860
});
836861
}
837862
}

sdk/src/androidTest/java/ly/count/android/sdk/CountlyStoreExplicitModeTests.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ public class CountlyStoreExplicitModeTests {
4343

4444
@Before
4545
public void setUp() {
46+
// Reset the shared Countly singleton — without this, init() state from a prior
47+
// test class in the suite leaks into our new Countly() instances and dirties the
48+
// event/request caches before the "this should perform no write" assertions can
49+
// measure them. The other suites (ContentOverlayViewTests,
50+
// ModuleConfigurationTests, ...) do the same halt+clear in setUp; this test class
51+
// was the odd one out and produced ordering-dependent flakes.
52+
Countly.sharedInstance().halt();
53+
TestUtils.getCountlyStore().clear();
54+
4655
Countly.sharedInstance().setLoggingEnabled(true);
4756
store = new CountlyStore(TestUtils.getContext(), mock(ModuleLog.class), false);
4857
sp = store;

0 commit comments

Comments
 (0)