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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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
Expand Down
136 changes: 136 additions & 0 deletions sdk/src/androidTest/java/ly/count/android/sdk/ModuleViewsTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<String> viewTimeline() throws JSONException {
Map<String, String> viewNamesByID = new HashMap<>();
List<String> timeline = new ArrayList<>();
for (Map<String, String> 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<String> timeline = viewTimeline();
Assert.assertEquals("tap@" + a, timeline.get(timeline.size() - 1));
}
//todo extract orientation tests
}
5 changes: 4 additions & 1 deletion sdk/src/main/java/ly/count/android/sdk/Countly.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 46 additions & 15 deletions sdk/src/main/java/ly/count/android/sdk/ModuleViews.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,6 +35,8 @@ public class ModuleViews extends ModuleBase implements ViewIdProvider {

Class[] autoTrackingActivityExceptions = null;//excluded activities from automatic view tracking

WeakReference<Activity> automaticViewActivity = null;//the activity the last automatic view was started for

Map<String, Object> automaticViewSegmentation = new HashMap<>();//automatic view segmentation

final Map<String, ViewData> viewDataMap = new ConcurrentHashMap<>(); // map viewIDs to its viewData
Expand Down Expand Up @@ -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
Expand All @@ -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
*
Expand Down Expand Up @@ -653,6 +683,7 @@ void halt() {
automaticViewSegmentation = null;
}
autoTrackingActivityExceptions = null;
automaticViewActivity = null;
}

public class Views {
Expand Down
Loading