From e39869d069c722f057375cf6212a068ea4bfaff8 Mon Sep 17 00:00:00 2001
From: ostfrost <18351132+ostfrost@users.noreply.github.com>
Date: Wed, 21 Oct 2026 16:45:54 +0000
Subject: [PATCH 1/2] feat: add Sift.setScreenTitle to set and clear the
reported screen name
The Activity class name was the only source of the screen name and null could not clear it, so a React Native app had no way to report or reset one; this mirrors the iOS SDK's setScreenTitle:. Released as 1.4.0.
---
CHANGELOG.md | 5 ++
DESIGNDOC.md | 9 +-
README.md | 30 +++++++
gradle.properties | 4 +-
.../android/AppStateCollector.java | 19 ++++-
.../main/java/siftscience/android/Sift.java | 27 ++++++
.../java/siftscience/android/SiftTest.java | 82 +++++++++++++++++++
7 files changed, 172 insertions(+), 4 deletions(-)
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();
From f22b5105b8c8fc64f47ad00442ac2b61bc6ac351 Mon Sep 17 00:00:00 2001
From: ostfrost <18351132+ostfrost@users.noreply.github.com>
Date: Wed, 21 Oct 2026 19:12:14 +0000
Subject: [PATCH 2/2] ci: stop setup-android from installing the removed tools
package
The action's default package list includes tools, which sdkmanager no longer provides, so the step failed before the build ran.
---
.github/workflows/ci.yml | 2 ++
1 file changed, 2 insertions(+)
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