Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,7 @@ upload-plugin/build/pluginUnderTestMetadata/plugin-under-test-metadata.propertie
upload-plugin/build/
app-native/.cxx/
.vscode/

# Content/feedback UI test runner artifacts (videos, logcat, verdicts)
.github/scripts/test_output/
.github/scripts/__pycache__/
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
* Added gradle configuration cache support to upload symbols plugin.
* 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.
* 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.
* Mitigated a memory leak where the content overlay retained the activity it was first opened in across subsequent activity transitions.
* Mitigated `IncorrectContextUseViolation` StrictMode warnings from non-UI context use in display metrics and content overlay construction.
* Mitigated an issue where content overlays and feedback widgets blocked keyboard input on the host activity.
* Mitigated a memory retention issue where content overlays and feedback widgets were briefly held after closing.
* Mitigated a memory leak where the content overlay retained its initial host activity across activity transitions.
* Mitigated a memory leak where content overlays and feedback widgets remained referenced via lifecycle callbacks when the host activity finished.

## 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.
Expand Down
11 changes: 11 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,24 @@ android {
}
}

buildFeatures {
// Required so `BuildConfig.COUNTLY_SERVER_URL` / `COUNTLY_APP_KEY`
// (declared below) get generated. AGP 8+ disables BuildConfig by default.
buildConfig true
}

defaultConfig {
applicationId "ly.count.android.demo"
minSdk 21
targetSdk 35
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

buildConfigField "String", "COUNTLY_SERVER_URL",
"\"${System.getenv('COUNTLY_SERVER_URL') ?: project.findProperty('countlyServerUrl') ?: 'https://your.server.ly'}\""
buildConfigField "String", "COUNTLY_APP_KEY",
"\"${System.getenv('COUNTLY_APP_KEY') ?: project.findProperty('countlyAppKey') ?: 'YOUR_APP_KEY'}\""
}
buildTypes {
debug {
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,14 @@
<activity
android:name=".ActivityExampleFeedback"
android:label="@string/activity_name_feedback"
android:configChanges="orientation|screenSize"/>
android:configChanges="orientation|screenSize"
android:exported="true"/>

<activity
android:name=".ActivityExampleContentZone"
android:label="@string/activity_name_content_zone"
android:configChanges="orientation|screenSize"/>
android:configChanges="orientation|screenSize"
android:exported="true"/>

<activity
android:name=".ActivityExampleFragments"
Expand Down
25 changes: 22 additions & 3 deletions app/src/main/java/ly/count/android/demo/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import android.os.Bundle;
import android.os.StrictMode;
import android.util.Log;
import android.webkit.WebView;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.google.android.gms.tasks.OnCompleteListener;
Expand All @@ -35,9 +36,9 @@
import static ly.count.android.sdk.messaging.CountlyPush.COUNTLY_BROADCAST_PERMISSION_POSTFIX;

public class App extends Application {
/** You should use try.count.ly instead of YOUR_SERVER for the line below if you are using Countly trial service */
private final static String COUNTLY_SERVER_URL = "https://your.server.ly";
private final static String COUNTLY_APP_KEY = "YOUR_APP_KEY";

private static String COUNTLY_SERVER_URL = "https://your.server.ly";
private static String COUNTLY_APP_KEY = "YOUR_APP_KEY";
private final static String DEFAULT_URL = "https://your.server.ly";
private final static String DEFAULT_APP_KEY = "YOUR_APP_KEY";

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

// Enable WebView remote debugging so the test runner can attach to the
// content/feedback widget's DOM via Chrome DevTools Protocol over an
// adb-forwarded socket. Process-wide flag — affects every WebView in
// this process, including the SDK's overlay WebView. Debug-only;
// BuildConfig.DEBUG is true on debug builds, false on release.
if (BuildConfig.DEBUG) {
WebView.setWebContentsDebuggingEnabled(true);
}

COUNTLY_SERVER_URL =
DEFAULT_URL.equals(BuildConfig.COUNTLY_SERVER_URL)
? DEFAULT_URL
: BuildConfig.COUNTLY_SERVER_URL;
COUNTLY_APP_KEY =
DEFAULT_APP_KEY.equals(BuildConfig.COUNTLY_APP_KEY)
? DEFAULT_APP_KEY
: BuildConfig.COUNTLY_APP_KEY;

if (DEFAULT_URL.equals(COUNTLY_SERVER_URL) || DEFAULT_APP_KEY.equals(COUNTLY_APP_KEY)) {
Log.e("CountlyDemo", "Please provide correct COUNTLY_SERVER_URL and COUNTLY_APP_KEY");
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package ly.count.android.demo

import android.os.Build
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy.Builder
import android.os.StrictMode.VmPolicy
import android.os.strictmode.UntaggedSocketViolation
import android.os.strictmode.Violation
import android.util.Log
import java.util.concurrent.Executors

object StrictModeConfigurator {

private val penaltyExecutor by lazy { Executors.newSingleThreadExecutor() }

private val threadPolicy: StrictMode.ThreadPolicy
get() = Builder()
.detectAll()
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
penaltyListener(penaltyExecutor) { violation ->
val knownIssue = knownThreadViolations.any { it(violation) }
if (!knownIssue) Log.w("StrictMode", null, violation)
}
} else {
penaltyLog()
}
}
.penaltyDeathOnNetwork()
.build()

private val knownThreadViolations: List<Violation.() -> Boolean> by lazy {
listOf(
// add known violations if any
)
}

private val vmPolicy: VmPolicy
get() = VmPolicy.Builder()
.apply {
detectActivityLeaks()
detectLeakedSqlLiteObjects()
detectLeakedClosableObjects()
detectLeakedRegistrationObjects()
detectFileUriExposure()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
detectCleartextNetwork()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
detectContentUriWithoutPermission()
detectUntaggedSockets() // okhttp "issue"
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
detectCredentialProtectedWhileLocked()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
detectIncorrectContextUse() // countly has known issue
detectUnsafeIntentLaunch()
}
}
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
penaltyListener(penaltyExecutor) { violation ->
val knownIssue = knownVmViolations.any { it(violation) }
if (!knownIssue) Log.w("StrictMode", null, violation)
}
} else {
penaltyLog()
}
}
.penaltyDeathOnFileUriExposure()
.build()

private val knownVmViolations: List<Violation.() -> Boolean> by lazy {
listOfNotNull(
{
this is UntaggedSocketViolation && stackTrace.any {
it.className.contains("ImmediateRequestMaker") || it.className.contains("ConnectionProcessor") // countly
}
},
)
}

@JvmStatic
fun configure() {
StrictMode.setThreadPolicy(threadPolicy)
StrictMode.setVmPolicy(vmPolicy)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -539,9 +539,19 @@ public void createWindowParams_correctTypeAndFlags() {
Assert.assertEquals("Type should be TYPE_APPLICATION",
WindowManager.LayoutParams.TYPE_APPLICATION, params.type);

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

Expand Down Expand Up @@ -794,12 +804,21 @@ public void contentUrlAction_noQueryParams_returnsFalse() {
// ===================== 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.
* Structural invariant: the overlay's View.mContext must not pin the
* constructing Activity. The overlay outlives activity transitions, and
* View.mContext can never be swapped after construction — if it's the
* Activity, that Activity stays GC-pinned for the overlay's full lifetime.
*
* 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.
* The exact context type is API-dependent (see ContentOverlayView#resolveOverlayContext):
* - Pre-API 31: Application context.
* - API 31+: createConfigurationContext from the Activity — a ContextImpl
* wrapper that holds an IBinder token, not the Activity instance, so
* GC isn't blocked. Required for StrictMode#detectIncorrectContextUse.
*
* In both cases, getApplicationContext() resolves to the same Application.
* The test asserts both that the context is NOT the Activity and that it
* routes back to the right Application — which is the actual leak-avoidance
* contract independent of API level.
*/
@Test
public void constructor_usesApplicationContext_notActivity() {
Expand All @@ -811,15 +830,19 @@ public void constructor_usesApplicationContext_notActivity() {
+ "that Activity for the lifetime of the overlay.",
activity, overlay.getContext());
Assert.assertSame(
"ContentOverlayView.mContext must be the Application context.",
activity.getApplicationContext(), overlay.getContext());
"ContentOverlayView.mContext must resolve to the same Application as the "
+ "constructing Activity (Application directly on <API 31, "
+ "ConfigurationContext-of-Activity on API 31+).",
activity.getApplicationContext(),
overlay.getContext().getApplicationContext());
});
}

/**
* 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.
* Same invariant for the embedded WebView. Even with the wrapper View not
* pinning the Activity, a WebView constructed with the Activity directly
* would still pin it via its own mContext. See
* constructor_usesApplicationContext_notActivity for the API-level rationale.
*/
@Test
public void webView_usesApplicationContext_notActivity() {
Expand All @@ -830,8 +853,10 @@ public void webView_usesApplicationContext_notActivity() {
"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());
"ContentOverlayView's WebView.mContext must resolve to the same Application "
+ "as the constructing Activity.",
activity.getApplicationContext(),
overlay.webView.getContext().getApplicationContext());
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ public class CountlyStoreExplicitModeTests {

@Before
public void setUp() {
// Reset the shared Countly singleton — without this, init() state from a prior
// test class in the suite leaks into our new Countly() instances and dirties the
// event/request caches before the "this should perform no write" assertions can
// measure them. The other suites (ContentOverlayViewTests,
// ModuleConfigurationTests, ...) do the same halt+clear in setUp; this test class
// was the odd one out and produced ordering-dependent flakes.
Countly.sharedInstance().halt();
TestUtils.getCountlyStore().clear();

Countly.sharedInstance().setLoggingEnabled(true);
store = new CountlyStore(TestUtils.getContext(), mock(ModuleLog.class), false);
sp = store;
Expand Down
Loading
Loading