diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6feec8c..cfd9dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: cache: gradle - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: '' - name: Install Android SDK packages run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" - name: Lint diff --git a/CHANGELOG.md b/CHANGELOG.md index e2307c3..3b5021a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## [1.4.0] - 2026-10-21 + +### Added +- `Sift.setScreenTitle(String)` sets the screen name reported with App State events, overriding the automatically collected Activity class name. Passing `null` or an empty string resets it to the automatic name. Mirrors the iOS SDK's `setScreenTitle:`, so cross-platform wrappers can set and clear a screen name the same way on both platforms. + ## [1.3.1] - 2026-03-03 ### Breaking Changes diff --git a/DESIGNDOC.md b/DESIGNDOC.md index c2290e8..6563a8e 100644 --- a/DESIGNDOC.md +++ b/DESIGNDOC.md @@ -126,7 +126,7 @@ Class diagram of AndroidDeviceLocation The AndroidAppState collects the following informations: - **activity\_class\_name** : {type: string, required: false} - - The activity class name indicates the current activity/fragment class name from where the data are collected. + - The activity class name indicates the current activity/fragment class name from where the data are collected; overridden by the screen name set through `setScreenTitle`. - **location** : {type: AndroidDeviceLocation, required: false} - The location consists of collective information of latitude, longitude, accuracy and the time at which data was collected as shown in the [section 3.1](#31-androidDeviceLocation). (_Have data only if the sift configuration and permissions are enabled_) - **sdk\_version** : {type: string, required: false} @@ -328,6 +328,11 @@ Following are the static API to interact with SDK: - It will set the provided id as the userId inside the sift instance. - **unsetUserId**() - Which removes any presetted useId to null +- **setScreenTitle**(_title_) + - It will set a screen name that is attached to every later App State collection, until it is changed or reset. + - The name overrides the Activity class name collected automatically. Passing null or an empty string resets it, after which the automatic name is used again. + - It does not collect by itself; call Sift.collect() to send an event right away. + - The title is not persisted, so it does not survive a process restart. ### 4.2 SIFT IMPL @@ -499,6 +504,8 @@ It provides the following instance methods: - **setActivityName**(_activityName_) - Which will assign the current activity class name as the provided name. +- **setManualScreenTitle**(_title_) + - Which records the screen name used instead of the automatic activity class name in all later collections. A null or empty title resets it. - **collect**() - This will request location updates if Sift.Config _disallowLocationCollection_ is false and location permission is enabled by the user which was requested by your application (_NB: Sift will not request permissions that are not granted by the user from your application_). Otherwise it will collect the rest of app state events without location information. - The location collection is managed by FusedLocationProviderClient. The location request is configured as PRIORITY\_HIGH\_ACCURACY with one minute interval and 10 seconds fastest interval. diff --git a/README.md b/README.md index c262235..533201c 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,21 @@ If the user logs out of your application, you should unset the user id: Sift.unsetUserId(); ``` +#### Set a custom screen name + +Sift reports the Activity class name with App State events. If your application +does not map screens to Activities, set the screen name yourself: + +```java +Sift.setScreenTitle("Checkout"); +``` + +Pass `null` or an empty string to clear it and go back to the automatic name: + +```java +Sift.setScreenTitle(null); +``` + ### Custom Integration #### Initialize Sift in your main Activity @@ -209,6 +224,21 @@ If the user logs out of your application, you should unset the user id: Sift.unsetUserId(); ``` +#### Set a custom screen name + +Sift reports the Activity class name with App State events. If your application +does not map screens to Activities, set the screen name yourself: + +```java +Sift.setScreenTitle("Checkout"); +``` + +Pass `null` or an empty string to clear it and go back to the automatic name: + +```java +Sift.setScreenTitle(null); +``` + ## Submitting an issue diff --git a/gradle.properties b/gradle.properties index 8c9ad3b..0701de8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,8 +17,8 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -VERSION_NAME=1.3.1 -VERSION_CODE=26 +VERSION_NAME=1.4.0 +VERSION_CODE=27 GROUP=com.siftscience diff --git a/sift/src/main/java/siftscience/android/AppStateCollector.java b/sift/src/main/java/siftscience/android/AppStateCollector.java index 4e97357..b7dea33 100644 --- a/sift/src/main/java/siftscience/android/AppStateCollector.java +++ b/sift/src/main/java/siftscience/android/AppStateCollector.java @@ -54,6 +54,7 @@ public class AppStateCollector { private final Context context; private String activityClassName; + private volatile String manualScreenTitle; private boolean acquiredNewLocation; private LocationRequest locationRequest; private FusedLocationProviderClient mFusedLocationClient; @@ -83,6 +84,22 @@ public void setActivityName(String activityName) { this.activityClassName = activityName; } + /** + * Record the screen name used instead of the automatic Activity class name + * in all later collections. + * + * A null or empty title resets it; the automatic name is used again. + */ + public void setManualScreenTitle(String title) { + this.manualScreenTitle = (title == null || title.isEmpty()) ? null : title; + } + + /** @return the manual screen title when one is set, the automatic name otherwise. */ + private String screenTitle() { + String manual = this.manualScreenTitle; + return manual != null ? manual : this.activityClassName; + } + public void collect() { if (!sift.getConfig().disallowLocationCollection && this.mFusedLocationClient != null && @@ -200,7 +217,7 @@ private AndroidAppStateJson get() { List ipAddresses = getIpAddresses(); AndroidAppStateJson androidAppStateJson = new AndroidAppStateJson() - .withActivityClassName(this.activityClassName) + .withActivityClassName(this.screenTitle()) .withBatteryLevel(batteryLevel) .withBatteryState((long) status) .withBatteryHealth((long) health) diff --git a/sift/src/main/java/siftscience/android/Sift.java b/sift/src/main/java/siftscience/android/Sift.java index 409dfe9..d315d0e 100644 --- a/sift/src/main/java/siftscience/android/Sift.java +++ b/sift/src/main/java/siftscience/android/Sift.java @@ -45,6 +45,8 @@ public final class Sift { private static volatile AppStateCollector appStateCollector; private static volatile String unboundUserId; private static volatile boolean hasUnboundUserId = false; + private static volatile String unboundScreenTitle; + private static volatile boolean hasUnboundScreenTitle = false; private static volatile ExecutorService executors; //================================================================================ @@ -81,6 +83,11 @@ public static void open(@NonNull Context context, appStateCollector = new AppStateCollector(instance, c); unboundUserId = null; hasUnboundUserId = false; + if (hasUnboundScreenTitle) { + appStateCollector.setManualScreenTitle(unboundScreenTitle); + unboundScreenTitle = null; + hasUnboundScreenTitle = false; + } } else { if (config != null) { instance.setConfig(config); @@ -200,6 +207,26 @@ public static synchronized void unsetUserId() { setUserId(null); } + /** + * Set the screen name attached to every later App State collection, until it + * is changed or reset. + * + * Overrides the Activity class name collected automatically. Pass null or an + * empty string to reset, after which the automatic name is used again. It + * does not collect by itself; call Sift.collect() to send an event now. + * + * The title is not persisted, so it does not survive a process restart. + */ + public static synchronized void setScreenTitle(String title) { + AppStateCollector localAppStateCollector = appStateCollector; + if (localAppStateCollector != null) { + localAppStateCollector.setManualScreenTitle(title); + } else { + unboundScreenTitle = title; + hasUnboundScreenTitle = true; + } + } + //================================================================================ // Instance config //================================================================================ diff --git a/sift/src/test/java/siftscience/android/SiftTest.java b/sift/src/test/java/siftscience/android/SiftTest.java index a9f07a2..b557bc2 100644 --- a/sift/src/test/java/siftscience/android/SiftTest.java +++ b/sift/src/test/java/siftscience/android/SiftTest.java @@ -520,6 +520,88 @@ public void testAppStateCollector() { assertEquals(Sift.SDK_VERSION, event.getAndroidAppState().getSdkVersion()); } + @Test + public void testManualScreenTitleOverridesActivityName() { + MemorySharedPreferences preferences = new MemorySharedPreferences(); + + Sift.Config config = new Sift.Config.Builder().withDisallowLocationCollection(true).build(); + + SiftImpl sift = new SiftImpl(mockContext(preferences), config, "", + false, mockTaskManager()); + + AppStateCollector appStateCollector = new AppStateCollector(sift, mockContext(preferences)); + appStateCollector.setActivityName("MainActivity"); + appStateCollector.setManualScreenTitle("Checkout"); + + appStateCollector.collect(); + // Collect twice because the first one gets uploaded and flushed + appStateCollector.collect(); + + List eventList = sift.getQueue(SiftImpl.APP_STATE_QUEUE_IDENTIFIER).flush(); + assertEquals(1, eventList.size()); + assertEquals("Checkout", + eventList.get(0).getAndroidAppState().getActivityClassName()); + } + + @Test + public void testManualScreenTitleResetFallsBackToActivityName() { + MemorySharedPreferences preferences = new MemorySharedPreferences(); + + Sift.Config config = new Sift.Config.Builder().withDisallowLocationCollection(true).build(); + + SiftImpl sift = new SiftImpl(mockContext(preferences), config, "", + false, mockTaskManager()); + + AppStateCollector appStateCollector = new AppStateCollector(sift, mockContext(preferences)); + appStateCollector.setActivityName("MainActivity"); + appStateCollector.setManualScreenTitle("Checkout"); + + appStateCollector.collect(); + // Collect twice because the first one gets uploaded and flushed + appStateCollector.collect(); + assertEquals("Checkout", sift.getQueue(SiftImpl.APP_STATE_QUEUE_IDENTIFIER) + .flush().get(0).getAndroidAppState().getActivityClassName()); + + appStateCollector.setManualScreenTitle(null); + appStateCollector.collect(); + assertEquals("MainActivity", sift.getQueue(SiftImpl.APP_STATE_QUEUE_IDENTIFIER) + .flush().get(0).getAndroidAppState().getActivityClassName()); + + appStateCollector.setManualScreenTitle("Checkout"); + appStateCollector.setManualScreenTitle(""); + appStateCollector.collect(); + assertEquals("MainActivity", sift.getQueue(SiftImpl.APP_STATE_QUEUE_IDENTIFIER) + .flush().get(0).getAndroidAppState().getActivityClassName()); + } + + @Test + public void testUnboundSetScreenTitle() throws NoSuchFieldException, IllegalAccessException { + MemorySharedPreferences preferences = new MemorySharedPreferences(); + + resetSiftStatics(); + + Sift.setScreenTitle("Checkout"); + Sift.open(mockContext(preferences), + new Sift.Config.Builder().withDisallowLocationCollection(true).build()); + + Field collectorField = Sift.class.getDeclaredField("appStateCollector"); + collectorField.setAccessible(true); + AppStateCollector appStateCollector = (AppStateCollector) collectorField.get(Sift.class); + + Field titleField = AppStateCollector.class.getDeclaredField("manualScreenTitle"); + titleField.setAccessible(true); + assertEquals("Checkout", titleField.get(appStateCollector)); + } + + private void resetSiftStatics() throws NoSuchFieldException, IllegalAccessException { + for (String name : Arrays.asList("instance", "appStateCollector", + "devicePropertiesCollector")) { + Field field = Sift.class.getDeclaredField(name); + field.setAccessible(true); + field.set(Sift.class, null); + } + } + @Test public void testDevicePropertyCollector() throws InterruptedException { MemorySharedPreferences preferences = new MemorySharedPreferences();