diff --git a/CHANGELOG.md b/CHANGELOG.md index 4510894f5..bc84d20bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ * Mitigated an issue where "disableBackoffMechanism()" had no effect until SDK behavior settings arrived. * Mitigated a crash when a content or feedback widget was closed from a background thread, for example by a device ID change without merge or by removing consent. * Mitigated an issue where an auto-stopped view could stop being tracked when the app came back from the background. +* Mitigated an issue where, with automatic view tracking, coming back to an activity that had not been stopped, for example when a translucent activity closed, did not record that activity's view again. The view of the activity that was left kept counting, and events recorded meanwhile were attributed to it. +* Mitigated an issue where the app was treated as being in the background after moving from one activity to another. Crash reports were marked as happening in the background, and with visibility tracking, events were marked as recorded in the background. * Mitigated push notification image issues that could show a notification late, or not at all: * A notification waits at most 8 seconds for its images and is shown without the ones that have not arrived. * Images over 5 MB are left out, and large ones are scaled down while decoding. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java index 133f9fb9c..531f11db0 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleEventsTests.java @@ -887,6 +887,52 @@ public void recordEvent_visibilityTracking_bgFgSwitch() throws JSONException { validateEventInRQ("bg", TestUtils.map("cly_v", 0), 1, 0.0d, 0.0d, 6); } + /** + * "recordEvent" with visibility tracking enabled + * Validate that the app stays in the foreground while one activity opens another and the first one stops, + * and that it is in the background only once the last activity stops + * + * @throws JSONException if the JSON is not valid + */ + @Test + public void recordEvent_visibilityTracking_activitySwitch() throws JSONException { + CountlyConfig config = TestUtils.createBaseConfig(TestUtils.getContext()); + config.experimental.enableVisibilityTracking(); + config.setEventQueueSizeToSend(1); + Countly countly = new Countly().init(config); + + countly.onStart(Mockito.mock(Activity.class)); + countly.onStart(Mockito.mock(TestUtils.Activity2.class)); + countly.onStop(); + + countly.events().recordEvent("fg", TestUtils.map()); + Assert.assertEquals(1, visibilityOfEventInRQ("fg")); + + countly.onStop(); + + countly.events().recordEvent("bg", TestUtils.map()); + Assert.assertEquals(0, visibilityOfEventInRQ("bg")); + } + + /** + * The "cly_v" value of the first event with the given key in the request queue + */ + private static int visibilityOfEventInRQ(String key) throws JSONException { + for (Map request : TestUtils.getCurrentRQ()) { + if (request == null || !request.containsKey("events")) { + continue; + } + JSONArray events = new JSONArray(request.get("events")); + for (int i = 0; i < events.length(); i++) { + JSONObject event = events.getJSONObject(i); + if (key.equals(event.getString("key"))) { + return event.getJSONObject("segmentation").getInt("cly_v"); + } + } + } + throw new AssertionError("no event with key [" + key + "] in the request queue"); + } + /** * "recordEvent" with visibility tracking disabled * Validate that visibility tracking events are not recorded diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java index 5de7e1631..4c424d919 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java @@ -5,6 +5,7 @@ import android.os.Build; import androidx.annotation.NonNull; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; @@ -14,6 +15,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -2248,5 +2250,139 @@ static void validateView(String viewName, Double viewDuration, int idx, int size ModuleEventsTests.validateEventInRQ(TestUtils.commonDeviceId, ModuleViews.VIEW_EVENT_KEY, viewSegmentation, 1, 0.0, durationToAssert, id, pvid, "_CLY_", "_CLY_", idx, size, 0, 1); } + + /** + * The view and custom events in the request queue, in order: "+name" for a view start, "-name=N" for a view end + * with its duration in seconds, and "key@name" for a custom event with the view it was recorded in + */ + private List viewTimeline() throws JSONException { + Map viewNamesByID = new HashMap<>(); + List timeline = new ArrayList<>(); + for (Map request : TestUtils.getCurrentRQ()) { + if (request == null || !request.containsKey("events")) { + continue; + } + JSONArray events = new JSONArray(request.get("events")); + for (int i = 0; i < events.length(); i++) { + JSONObject event = events.getJSONObject(i); + String key = event.getString("key"); + JSONObject segmentation = event.optJSONObject("segmentation"); + if (ModuleViews.VIEW_EVENT_KEY.equals(key) && segmentation != null) { + String name = segmentation.getString("name"); + viewNamesByID.put(event.optString("id"), name); + timeline.add(segmentation.has("visit") ? "+" + name : "-" + name + "=" + Math.round(event.optDouble("dur", 0))); + } else if (!key.startsWith("[CLY]_")) { + timeline.add(key + "@" + viewNamesByID.get(event.optString("cvid"))); + } + } + } + return timeline; + } + + /** + * Automatic view tracking: an activity that comes back without being stopped, as after a translucent activity + * or a quick return, gets its view again, and the activity it left stops counting + */ + @Test + public void automaticViews_returnToActivityThatWasNotStopped() throws InterruptedException, JSONException { + CountlyConfig cc = TestUtils.createViewCountlyConfig(false, true, true, safeViewIDGenerator, null); + cc.setEventQueueSizeToSend(1); + Countly mCountly = new Countly().init(cc); + + Activity actA = mock(Activity.class); + Activity actB = mock(TestUtils.Activity2.class); + String a = actA.getClass().getSimpleName(); + String b = actB.getClass().getSimpleName(); + + mCountly.dispatchActivityStarted(actA); + mCountly.dispatchActivityResumed(actA); + Thread.sleep(1000); + mCountly.dispatchActivityStarted(actB); + mCountly.dispatchActivityResumed(actB); + Thread.sleep(1000); + mCountly.dispatchActivityResumed(actA); + mCountly.dispatchActivityStopped(actB); + Thread.sleep(2000); + mCountly.dispatchActivityStopped(actA); + + Assert.assertEquals(Arrays.asList("+" + a, "-" + a + "=1", "+" + b, "-" + b + "=1", "+" + a, "-" + a + "=2"), viewTimeline()); + } + + /** + * Automatic view tracking: an activity that resumes without having left, as after a dialog or the notification + * shade, keeps its view + */ + @Test + public void automaticViews_resumeWithoutLeavingKeepsTheView() throws InterruptedException, JSONException { + CountlyConfig cc = TestUtils.createViewCountlyConfig(false, true, true, safeViewIDGenerator, null); + cc.setEventQueueSizeToSend(1); + Countly mCountly = new Countly().init(cc); + + Activity actA = mock(Activity.class); + String a = actA.getClass().getSimpleName(); + + mCountly.dispatchActivityStarted(actA); + mCountly.dispatchActivityResumed(actA); + Thread.sleep(1000); + mCountly.dispatchActivityResumed(actA); + Thread.sleep(1000); + mCountly.dispatchActivityStopped(actA); + + Assert.assertEquals(Arrays.asList("+" + a, "-" + a + "=2"), viewTimeline()); + } + + /** + * Automatic view tracking: when an activity excluded from tracking closes, the activity below it keeps its view + */ + @Test + public void automaticViews_excludedActivityOnTopKeepsTheViewBelow() throws InterruptedException, JSONException { + CountlyConfig cc = TestUtils.createViewCountlyConfig(false, true, true, safeViewIDGenerator, null); + cc.setEventQueueSizeToSend(1); + cc.setAutomaticViewTrackingExclusions(new Class[] { TestUtils.Activity2.class }); + Countly mCountly = new Countly().init(cc); + + Activity actA = mock(Activity.class); + Activity actB = mock(TestUtils.Activity2.class); + String a = actA.getClass().getSimpleName(); + + mCountly.dispatchActivityStarted(actA); + mCountly.dispatchActivityResumed(actA); + Thread.sleep(1000); + mCountly.dispatchActivityStarted(actB); + mCountly.dispatchActivityResumed(actB); + Thread.sleep(1000); + mCountly.dispatchActivityResumed(actA); + mCountly.dispatchActivityStopped(actB); + Thread.sleep(1000); + mCountly.dispatchActivityStopped(actA); + + Assert.assertEquals(Arrays.asList("+" + a, "-" + a + "=3"), viewTimeline()); + } + + /** + * Automatic view tracking: an event recorded after returning to an activity that was not stopped belongs to that + * activity's view + */ + @Test + public void automaticViews_eventAfterReturnBelongsToTheReturnedActivity() throws JSONException { + CountlyConfig cc = TestUtils.createViewCountlyConfig(false, true, true, safeViewIDGenerator, null); + cc.setEventQueueSizeToSend(1); + Countly mCountly = new Countly().init(cc); + + Activity actA = mock(Activity.class); + Activity actB = mock(TestUtils.Activity2.class); + String a = actA.getClass().getSimpleName(); + + mCountly.dispatchActivityStarted(actA); + mCountly.dispatchActivityResumed(actA); + mCountly.dispatchActivityStarted(actB); + mCountly.dispatchActivityResumed(actB); + mCountly.dispatchActivityResumed(actA); + mCountly.dispatchActivityStopped(actB); + mCountly.events().recordEvent("tap"); + + List timeline = viewTimeline(); + Assert.assertEquals("tap@" + a, timeline.get(timeline.size() - 1)); + } //todo extract orientation tests } 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 6b91e0929..99fc70eef 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -1845,7 +1845,10 @@ void onStopInternal() { sessionsModule.endSessionInternal(); } - deviceInfo_.inBackground(); + //an activity stops only after the one replacing it started, so the app leaves the foreground with the last one + if (startedCount == 0) { + deviceInfo_.inBackground(); + } for (ModuleBase module : modules) { module.onActivityStopped(startedCount); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java index bb86842c8..5a5e84b40 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java @@ -5,6 +5,7 @@ import android.content.res.Resources; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; @@ -34,6 +35,8 @@ public class ModuleViews extends ModuleBase implements ViewIdProvider { Class[] autoTrackingActivityExceptions = null;//excluded activities from automatic view tracking + WeakReference automaticViewActivity = null;//the activity the last automatic view was started for + Map automaticViewSegmentation = new HashMap<>();//automatic view segmentation final Map viewDataMap = new ConcurrentHashMap<>(); // map viewIDs to its viewData @@ -587,21 +590,7 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { //automatic view tracking, resolved through the SBS precedence chain (server can override the local setting) if (configProvider.getAutomaticViewTrackingEnabled()) { - if (!isActivityInExceptionList(activity)) { - String usedActivityName = "NULL ACTIVITY"; - - if (activity != null) { - if (automaticTrackingShouldUseShortName) { - usedActivityName = activity.getClass().getSimpleName(); - } else { - usedActivityName = activity.getClass().getName(); - } - } - - startViewInternal(usedActivityName, automaticViewSegmentation, true); - } else { - L.d("[ModuleViews] [onStart] Ignoring activity because it's in the exception list"); - } + startAutomaticView(activity, "[onStart]"); } //orientation tracking @@ -613,6 +602,47 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { } } + /** + * Starts the automatic view of an activity that resumes while the last automatic view belongs to another one. + * An activity that was not stopped, as below a translucent activity or on a quick return, resumes without + * starting again. + * + * @param activity the activity that resumed + */ + @Override + void callbackOnActivityResumed(Activity activity) { + if (activity != null && configProvider.getAutomaticViewTrackingEnabled() && (automaticViewActivity == null || automaticViewActivity.get() != activity)) { + startAutomaticView(activity, "[onResume]"); + } + } + + /** + * Starts the automatic view of an activity that came into view, unless the activity is excluded from automatic + * view tracking + * + * @param activity the activity that came into view + * @param source the lifecycle callback it came into view with, for the log + */ + private void startAutomaticView(@Nullable Activity activity, @NonNull String source) { + if (isActivityInExceptionList(activity)) { + L.d("[ModuleViews] " + source + " Ignoring activity because it's in the exception list"); + return; + } + + String usedActivityName = "NULL ACTIVITY"; + + if (activity != null) { + if (automaticTrackingShouldUseShortName) { + usedActivityName = activity.getClass().getSimpleName(); + } else { + usedActivityName = activity.getClass().getName(); + } + } + + automaticViewActivity = activity == null ? null : new WeakReference<>(activity); + startViewInternal(usedActivityName, automaticViewSegmentation, true); + } + /** * Needed for mocking test result * @@ -653,6 +683,7 @@ void halt() { automaticViewSegmentation = null; } autoTrackingActivityExceptions = null; + automaticViewActivity = null; } public class Views {