From 12c009f11830a4e22ebaeccf30e5cacccf59ba67 Mon Sep 17 00:00:00 2001
From: Josh Yaganeh <319444+jyaganeh@users.noreply.github.com>
Date: Mon, 22 May 2023 11:04:39 -0700
Subject: [PATCH] Merge feature/live-update -> main (#1249)
---
.gitignore | 1 +
.idea/kotlinc.xml | 6 -
gradle/libs.versions.toml | 1 +
sample/build.gradle | 3 +
.../urbanairship/sample/CustomLiveUpdate.java | 37 ++
.../urbanairship/sample/SampleAutopilot.java | 19 +
.../urbanairship/sample/SampleLiveUpdate.java | 70 +++
.../sample/home/HomeFragment.java | 70 +++
sample/src/main/res/drawable/foxes.xml | 21 +
sample/src/main/res/drawable/tigers.xml | 18 +
sample/src/main/res/layout/fragment_home.xml | 66 +++
.../layout/live_update_notification_big.xml | 84 ++++
.../layout/live_update_notification_small.xml | 83 ++++
sample/src/main/res/values/strings.xml | 5 +
settings.gradle | 1 +
.../urbanairship/AirshipComponentGroups.java | 3 +-
.../main/java/com/urbanairship/UAirship.java | 4 +
.../com/urbanairship/job/JobDispatcher.java | 8 +-
.../com/urbanairship/modules/Modules.java | 21 +
.../liveupdate/LiveUpdateModuleFactory.java | 32 ++
.../com/urbanairship/push/PushMessage.java | 16 +
urbanairship-live-update/build.gradle | 42 ++
urbanairship-live-update/proguard-rules.pro | 21 +
.../1.json | 90 ++++
.../src/main/AndroidManifest.xml | 8 +
.../liveupdate/AirshipDispatchers.kt | 25 +
.../com/urbanairship/liveupdate/LiveUpdate.kt | 51 +++
.../liveupdate/LiveUpdateEvent.kt | 23 +
.../liveupdate/LiveUpdateHandler.kt | 73 +++
.../liveupdate/LiveUpdateManager.kt | 219 +++++++++
.../liveupdate/LiveUpdateModuleFactoryImpl.kt | 46 ++
.../liveupdate/LiveUpdateProcessor.kt | 288 ++++++++++++
.../liveupdate/LiveUpdateRegistrar.kt | 248 ++++++++++
.../api/ChannelBulkUpdateApiClient.kt | 164 +++++++
.../liveupdate/data/Converters.kt | 20 +
.../liveupdate/data/LiveUpdateDao.kt | 87 ++++
.../liveupdate/data/LiveUpdateDatabase.kt | 43 ++
.../liveupdate/data/LiveUpdateEntities.kt | 42 ++
.../LiveUpdateNotificationReceiver.kt | 73 +++
.../notification/LiveUpdatePayload.kt | 66 +++
.../notification/NotificationTimeoutCompat.kt | 48 ++
.../liveupdate/util/JsonExtensions.kt | 69 +++
.../liveupdate/LiveUpdateManagerTest.kt | 66 +++
.../liveupdate/LiveUpdateProcessorTest.kt | 431 ++++++++++++++++++
.../liveupdate/LiveUpdateRegistrarTest.kt | 127 ++++++
.../liveupdate/LiveUpdateStressTest.kt | 158 +++++++
.../urbanairship/liveupdate/TestHandler.kt | 21 +
.../api/ChannelBulkUpdateApiClientTest.kt | 125 +++++
.../liveupdate/data/LiveUpdateDaoTest.kt | 123 +++++
.../src/test/resources/robolectric.properties | 2 +
urbanairship-preference-center/build.gradle | 5 +-
51 files changed, 3363 insertions(+), 10 deletions(-)
delete mode 100644 .idea/kotlinc.xml
create mode 100644 sample/src/main/java/com/urbanairship/sample/CustomLiveUpdate.java
create mode 100644 sample/src/main/java/com/urbanairship/sample/SampleLiveUpdate.java
create mode 100644 sample/src/main/res/drawable/foxes.xml
create mode 100644 sample/src/main/res/drawable/tigers.xml
create mode 100644 sample/src/main/res/layout/live_update_notification_big.xml
create mode 100644 sample/src/main/res/layout/live_update_notification_small.xml
create mode 100644 urbanairship-core/src/main/java/com/urbanairship/modules/liveupdate/LiveUpdateModuleFactory.java
create mode 100644 urbanairship-live-update/build.gradle
create mode 100644 urbanairship-live-update/proguard-rules.pro
create mode 100644 urbanairship-live-update/schemas/com.urbanairship.liveupdate.data.LiveUpdateDatabase/1.json
create mode 100644 urbanairship-live-update/src/main/AndroidManifest.xml
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/AirshipDispatchers.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdate.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateEvent.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateHandler.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateManager.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateModuleFactoryImpl.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateProcessor.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateRegistrar.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClient.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/Converters.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDao.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDatabase.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateEntities.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdateNotificationReceiver.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdatePayload.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/NotificationTimeoutCompat.kt
create mode 100644 urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/util/JsonExtensions.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateManagerTest.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateProcessorTest.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateRegistrarTest.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateStressTest.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/TestHandler.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClientTest.kt
create mode 100644 urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/data/LiveUpdateDaoTest.kt
create mode 100644 urbanairship-live-update/src/test/resources/robolectric.properties
diff --git a/.gitignore b/.gitignore
index 4eee2c99c..8e512bd43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ captures/
.idea/misc.xml
.idea/kotlinScripting.xml
.idea/compiler.xml
+.idea/kotlinc.xml
# Android Studio 3 in .gitignore file.
.idea/caches/build_file_checksums.ser
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
deleted file mode 100644
index 2cfd1d9b2..000000000
--- a/.idea/kotlinc.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d4df44b4b..a0c9a3520 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -120,6 +120,7 @@ androidx-preferencektx = { module = "androidx.preference:preference-ktx", versio
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidx-recyclerview" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" }
androidx-startup-runtime = { module = "androidx.startup:startup-runtime", version.ref = "androidx-startup" }
androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefreshlayout" }
diff --git a/sample/build.gradle b/sample/build.gradle
index 85149b500..840734eab 100644
--- a/sample/build.gradle
+++ b/sample/build.gradle
@@ -67,6 +67,9 @@ dependencies {
// Airship Automation (In-App)
implementation project(':urbanairship-automation')
+ // Airship Live Updates
+ implementation project(':urbanairship-live-update')
+
// Airship location
implementation project(':urbanairship-location')
// Allows Airship location services to use Fused Location
diff --git a/sample/src/main/java/com/urbanairship/sample/CustomLiveUpdate.java b/sample/src/main/java/com/urbanairship/sample/CustomLiveUpdate.java
new file mode 100644
index 000000000..8203e05fa
--- /dev/null
+++ b/sample/src/main/java/com/urbanairship/sample/CustomLiveUpdate.java
@@ -0,0 +1,37 @@
+package com.urbanairship.sample;
+
+import android.content.Context;
+import android.util.Log;
+
+import com.urbanairship.json.JsonMap;
+import com.urbanairship.liveupdate.LiveUpdate;
+import com.urbanairship.liveupdate.LiveUpdateEvent;
+import com.urbanairship.liveupdate.LiveUpdateCustomHandler;
+import com.urbanairship.liveupdate.LiveUpdateResult;
+
+import org.jetbrains.annotations.NotNull;
+
+import androidx.annotation.NonNull;
+
+// TODO(live-update): Implement a custom live update handler to feed data to a widget.
+public class CustomLiveUpdate implements LiveUpdateCustomHandler {
+ @Override
+ @NotNull
+ public LiveUpdateResult onUpdate(@NonNull Context context, @NonNull LiveUpdateEvent event, @NonNull LiveUpdate update) {
+
+ Log.d("CustomLiveUpdate", "onUpdate: action=" + event + ", update=" + update);
+
+ if (event == LiveUpdateEvent.END) {
+ // Dismiss the live update on STOP. The default behavior will leave the Live Update
+ // active until the dismissal time is reached.
+ return LiveUpdateResult.cancel();
+ }
+
+ JsonMap content = update.getContent();
+ int teamOneScore = content.opt("team_one_score").getInt(0);
+ int teamTwoScore = content.opt("team_two_score").getInt(0);
+ String statusUpdate = content.opt("status_update").optString();
+
+ return LiveUpdateResult.ok();
+ }
+}
diff --git a/sample/src/main/java/com/urbanairship/sample/SampleAutopilot.java b/sample/src/main/java/com/urbanairship/sample/SampleAutopilot.java
index 01b4f89c7..f4b0e9576 100644
--- a/sample/src/main/java/com/urbanairship/sample/SampleAutopilot.java
+++ b/sample/src/main/java/com/urbanairship/sample/SampleAutopilot.java
@@ -10,10 +10,15 @@
import com.urbanairship.AirshipConfigOptions;
import com.urbanairship.Autopilot;
import com.urbanairship.UAirship;
+import com.urbanairship.liveupdate.LiveUpdateManager;
import com.urbanairship.messagecenter.MessageCenter;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.core.app.NotificationChannelCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+import static androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH;
/**
* Autopilot that enables user notifications on first run.
@@ -36,6 +41,20 @@ public void onAirshipReady(@NonNull UAirship airship) {
airship.getPushManager().setUserNotificationsEnabled(true);
}
+ // Create notification channel for Live Updates.
+ NotificationChannelCompat sportsChannel =
+ new NotificationChannelCompat.Builder("sports", IMPORTANCE_HIGH)
+ .setDescription("Live sports updates!")
+ .setName("Sports!")
+ .setVibrationEnabled(false)
+ .build();
+
+ Context context = UAirship.getApplicationContext();
+ NotificationManagerCompat.from(context).createNotificationChannel(sportsChannel);
+
+ // Register handlers for Live Updates.
+ LiveUpdateManager.shared().register("sports", new SampleLiveUpdate());
+
MessageCenter.shared().setOnShowMessageCenterListener(messageId -> {
// Use an implicit navigation deep link for now as explicit deep links are broken
// with multi navigation host fragments
diff --git a/sample/src/main/java/com/urbanairship/sample/SampleLiveUpdate.java b/sample/src/main/java/com/urbanairship/sample/SampleLiveUpdate.java
new file mode 100644
index 000000000..3ceb7e87d
--- /dev/null
+++ b/sample/src/main/java/com/urbanairship/sample/SampleLiveUpdate.java
@@ -0,0 +1,70 @@
+package com.urbanairship.sample;
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.widget.RemoteViews;
+
+import com.urbanairship.Logger;
+import com.urbanairship.json.JsonMap;
+import com.urbanairship.liveupdate.LiveUpdate;
+import com.urbanairship.liveupdate.LiveUpdateEvent;
+import com.urbanairship.liveupdate.LiveUpdateNotificationHandler;
+import com.urbanairship.liveupdate.LiveUpdateResult;
+import com.urbanairship.util.PendingIntentCompat;
+
+import org.jetbrains.annotations.NotNull;
+
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+
+
+public class SampleLiveUpdate implements LiveUpdateNotificationHandler {
+ @Override
+ @NotNull
+ public LiveUpdateResult onUpdate(@NonNull Context context, @NonNull LiveUpdateEvent event, @NonNull LiveUpdate update) {
+
+ Logger.debug("SampleLiveUpdate - onUpdate: action=" + event + ", update=" + update);
+
+ if (event == LiveUpdateEvent.END) {
+ // Dismiss the live update on STOP. The default behavior will leave the Live Update
+ // in the notification tray until the dismissal time is reached or the user dismisses it.
+ return LiveUpdateResult.cancel();
+ }
+
+ JsonMap content = update.getContent();
+ int teamOneScore = content.opt("team_one_score").getInt(0);
+ int teamTwoScore = content.opt("team_two_score").getInt(0);
+ String statusUpdate = content.opt("status_update").optString();
+
+ RemoteViews bigLayout = new RemoteViews(context.getPackageName(), R.layout.live_update_notification_big);
+ bigLayout.setTextViewText(R.id.teamOneScore, String.valueOf(teamOneScore));
+ bigLayout.setTextViewText(R.id.teamTwoScore, String.valueOf(teamTwoScore));
+ bigLayout.setTextViewText(R.id.statusUpdate, statusUpdate);
+
+ RemoteViews smallLayout = new RemoteViews(context.getPackageName(), R.layout.live_update_notification_small);
+ smallLayout.setTextViewText(R.id.teamOneScore, String.valueOf(teamOneScore));
+ smallLayout.setTextViewText(R.id.teamTwoScore, String.valueOf(teamTwoScore));
+
+ Intent launchIntent = context.getPackageManager()
+ .getLaunchIntentForPackage(context.getPackageName())
+ .addCategory(update.getName())
+ .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .setPackage(null);
+
+ PendingIntent contentIntent = PendingIntentCompat.getActivity(
+ context, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE);
+
+ NotificationCompat.Builder builder =
+ new NotificationCompat.Builder(context, "sports")
+ .setSmallIcon(R.drawable.ic_notification)
+ .setPriority(NotificationCompat.PRIORITY_MAX)
+ .setCategory(NotificationCompat.CATEGORY_EVENT)
+ .setStyle(new NotificationCompat.DecoratedCustomViewStyle())
+ .setCustomContentView(smallLayout)
+ .setCustomBigContentView(bigLayout)
+ .setContentIntent(contentIntent);
+
+ return LiveUpdateResult.ok(builder);
+ }
+}
diff --git a/sample/src/main/java/com/urbanairship/sample/home/HomeFragment.java b/sample/src/main/java/com/urbanairship/sample/home/HomeFragment.java
index 81bf4b895..89c9c0280 100644
--- a/sample/src/main/java/com/urbanairship/sample/home/HomeFragment.java
+++ b/sample/src/main/java/com/urbanairship/sample/home/HomeFragment.java
@@ -10,9 +10,13 @@
import com.urbanairship.actions.ActionRunRequest;
import com.urbanairship.actions.ClipboardAction;
+import com.urbanairship.json.JsonMap;
+import com.urbanairship.liveupdate.LiveUpdateManager;
import com.urbanairship.sample.R;
import com.urbanairship.sample.databinding.FragmentHomeBinding;
+import java.util.concurrent.atomic.AtomicInteger;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
@@ -42,6 +46,8 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c
});
});
+ setupLiveUpdateTestButtons(binding);
+
return binding.getRoot();
}
@@ -52,4 +58,68 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
NavigationUI.setupWithNavController(toolbar, Navigation.findNavController(view));
}
+ // TODO: Replace with something less hacky when backend is ready to send real Live Updates.
+ // Should live update stuff even be on the home screen? Could be in settings instead...
+ private void setupLiveUpdateTestButtons(FragmentHomeBinding binding) {
+ AtomicInteger score1 = new AtomicInteger();
+ AtomicInteger score2 = new AtomicInteger();
+
+ // Start button
+ binding.startLiveUpdate.setOnClickListener(v -> {
+ JsonMap content = JsonMap.newBuilder()
+ .put("team_one_score", 0)
+ .put("team_two_score", 0)
+ .put("status_update", "Match start!")
+ .build();
+
+ LiveUpdateManager.shared().start("foxes-tigers", "sports", content);
+ });
+
+ // +1 Foxes button
+ binding.updateLiveUpdate1.setOnClickListener(v -> {
+ JsonMap content = JsonMap.newBuilder()
+ .put("team_one_score", score1.getAndIncrement())
+ .put("team_two_score", score2.get())
+ .put("status_update", "Foxes score!")
+ .build();
+
+ LiveUpdateManager.shared().update("foxes-tigers", content);
+ });
+
+ // +1 Tigers button
+ binding.updateLiveUpdate2.setOnClickListener(v -> {
+ JsonMap content = JsonMap.newBuilder()
+ .put("team_one_score", score1.get())
+ .put("team_two_score", score2.getAndIncrement())
+ .put("status_update", "Tigers score!")
+ .build();
+
+ LiveUpdateManager.shared().update("foxes-tigers", content);
+ });
+
+ // Stop button
+ binding.stopLiveUpdate.setOnClickListener(v -> {
+ int s1 = score1.get();
+ int s2 = score2.get();
+ String status;
+ if (s1 == s2) {
+ status = "It's a tie!";
+ } else if (s1 > s2) {
+ status = "Foxes win!";
+ } else {
+ status = "Tigers win!";
+ }
+
+ JsonMap content = JsonMap.newBuilder()
+ .put("teamOneScore", s1)
+ .put("team_two_score", s2)
+ .put("status_update", status)
+ .build();
+
+ LiveUpdateManager.shared().stop("foxes-tigers", content);
+
+ score1.set(0);
+ score2.set(0);
+ });
+ }
}
diff --git a/sample/src/main/res/drawable/foxes.xml b/sample/src/main/res/drawable/foxes.xml
new file mode 100644
index 000000000..8a0f552c3
--- /dev/null
+++ b/sample/src/main/res/drawable/foxes.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/drawable/tigers.xml b/sample/src/main/res/drawable/tigers.xml
new file mode 100644
index 000000000..f1f806dfb
--- /dev/null
+++ b/sample/src/main/res/drawable/tigers.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/sample/src/main/res/layout/fragment_home.xml b/sample/src/main/res/layout/fragment_home.xml
index 88993ce4f..6c99da37d 100644
--- a/sample/src/main/res/layout/fragment_home.xml
+++ b/sample/src/main/res/layout/fragment_home.xml
@@ -57,6 +57,72 @@
tools:text="a61635e7-60c5-47e7-b9fb-870754e70a86" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/layout/live_update_notification_big.xml b/sample/src/main/res/layout/live_update_notification_big.xml
new file mode 100644
index 000000000..9a60c0ff0
--- /dev/null
+++ b/sample/src/main/res/layout/live_update_notification_big.xml
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/layout/live_update_notification_small.xml b/sample/src/main/res/layout/live_update_notification_small.xml
new file mode 100644
index 000000000..0002da810
--- /dev/null
+++ b/sample/src/main/res/layout/live_update_notification_small.xml
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml
index 1e5d55602..0cc8c4ace 100644
--- a/sample/src/main/res/values/strings.xml
+++ b/sample/src/main/res/values/strings.xml
@@ -36,4 +36,9 @@
- %d unread messages
+
+ Foxes
+ Tigers
+ VS
+
diff --git a/settings.gradle b/settings.gradle
index bf6573385..aa89b1c96 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -26,6 +26,7 @@ include ':urbanairship-accengage',
':urbanairship-hms-stub',
':urbanairship-layout',
':urbanairship-layout:playground',
+ ':urbanairship-live-update',
':urbanairship-location',
':urbanairship-message-center',
':urbanairship-preference',
diff --git a/urbanairship-core/src/main/java/com/urbanairship/AirshipComponentGroups.java b/urbanairship-core/src/main/java/com/urbanairship/AirshipComponentGroups.java
index 6477dbe64..465df7d91 100644
--- a/urbanairship-core/src/main/java/com/urbanairship/AirshipComponentGroups.java
+++ b/urbanairship-core/src/main/java/com/urbanairship/AirshipComponentGroups.java
@@ -15,7 +15,7 @@
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public interface AirshipComponentGroups {
- @IntDef({ NONE, PUSH, ANALYTICS, MESSAGE_CENTER, IN_APP, ACTION_AUTOMATION, NAMED_USER, LOCATION, CHANNEL, CHAT, CONTACT, PREFERENCE_CENTER })
+ @IntDef({ NONE, PUSH, ANALYTICS, MESSAGE_CENTER, IN_APP, ACTION_AUTOMATION, NAMED_USER, LOCATION, CHANNEL, CHAT, CONTACT, PREFERENCE_CENTER, LIVE_UPDATE })
@Retention(RetentionPolicy.SOURCE)
@interface Group {
}
@@ -32,4 +32,5 @@ public interface AirshipComponentGroups {
int CHAT = 8;
int CONTACT = 9;
int PREFERENCE_CENTER = 10;
+ int LIVE_UPDATE = 11;
}
diff --git a/urbanairship-core/src/main/java/com/urbanairship/UAirship.java b/urbanairship-core/src/main/java/com/urbanairship/UAirship.java
index 48738ad45..ccada5951 100644
--- a/urbanairship-core/src/main/java/com/urbanairship/UAirship.java
+++ b/urbanairship-core/src/main/java/com/urbanairship/UAirship.java
@@ -796,6 +796,10 @@ public void onUrlConfigUpdated() {
Module preferenceCenter = Modules.preferenceCenter(application, preferenceDataStore, privacyManager, remoteData);
processModule(preferenceCenter);
+ // Live Updates
+ Module liveUpdateManager = Modules.liveUpdateManager(application, preferenceDataStore, runtimeConfig, privacyManager, channel, pushManager);
+ processModule(liveUpdateManager);
+
for (AirshipComponent component : components) {
component.init();
}
diff --git a/urbanairship-core/src/main/java/com/urbanairship/job/JobDispatcher.java b/urbanairship-core/src/main/java/com/urbanairship/job/JobDispatcher.java
index 713452e38..858ed8e8e 100644
--- a/urbanairship-core/src/main/java/com/urbanairship/job/JobDispatcher.java
+++ b/urbanairship-core/src/main/java/com/urbanairship/job/JobDispatcher.java
@@ -160,8 +160,12 @@ protected void onStartJob(@NonNull JobInfo jobInfo, long runAttempt, @NonNull Co
jobRunner.run(jobInfo, (result) -> {
Logger.verbose("Job finished. Job info: %s, result: %s", jobInfo, result);
- if (result == JobResult.RETRY && runAttempt >= RESCHEDULE_RETRY_COUNT) {
- Logger.verbose("Job retry limit reached. Rescheduling for a later time. Job info: %s, work Id: %s", jobInfo);
+ boolean shouldRetry = result == JobResult.RETRY;
+ boolean shouldReschedule = runAttempt >= RESCHEDULE_RETRY_COUNT;
+ // Workaround for APPEND jobs, which we don't want to reschedule like other jobs.
+ boolean isAppend = jobInfo.getConflictStrategy() == JobInfo.APPEND;
+ if (shouldRetry && shouldReschedule && !isAppend) {
+ Logger.verbose("Job retry limit reached. Rescheduling for a later time. Job info: %s", jobInfo);
dispatch(jobInfo, RESCHEDULE_RETRY_DELAY_MS);
callback.accept(JobResult.FAILURE);
} else {
diff --git a/urbanairship-core/src/main/java/com/urbanairship/modules/Modules.java b/urbanairship-core/src/main/java/com/urbanairship/modules/Modules.java
index a1752c364..0606a57fb 100644
--- a/urbanairship-core/src/main/java/com/urbanairship/modules/Modules.java
+++ b/urbanairship-core/src/main/java/com/urbanairship/modules/Modules.java
@@ -20,6 +20,7 @@
import com.urbanairship.modules.automation.AutomationModuleFactory;
import com.urbanairship.modules.chat.ChatModuleFactory;
import com.urbanairship.modules.debug.DebugModuleFactory;
+import com.urbanairship.modules.liveupdate.LiveUpdateModuleFactory;
import com.urbanairship.modules.location.LocationModule;
import com.urbanairship.modules.location.LocationModuleFactory;
import com.urbanairship.modules.messagecenter.MessageCenterModuleFactory;
@@ -47,6 +48,7 @@ public class Modules {
private static final String DEBUG_MODULE_FACTORY = "com.urbanairship.debug.DebugModuleFactoryImpl";
private static final String AD_ID_FACTORY = "com.urbanairship.aaid.AdIdModuleFactoryImpl";
private static final String CHAT_FACTORY = "com.urbanairship.chat.ChatModuleFactoryImpl";
+ private static final String LIVE_UPDATE_FACTORY = "com.urbanairship.liveupdate.LiveUpdateModuleFactoryImpl";
private static final String PREFERENCE_CENTER_FACTORY = "com.urbanairship.preferencecenter.PreferenceCenterModuleFactoryImpl";
@Nullable
@@ -191,6 +193,25 @@ public static Module preferenceCenter(@NonNull Context context,
return null;
}
+ @Nullable
+ public static Module liveUpdateManager(@NonNull Context context,
+ @NonNull PreferenceDataStore dataStore,
+ @NonNull AirshipRuntimeConfig config,
+ @NonNull PrivacyManager privacyManager,
+ @NonNull AirshipChannel airshipChannel,
+ @NonNull PushManager pushManager) {
+ try {
+ LiveUpdateModuleFactory moduleFactory =
+ createFactory(LIVE_UPDATE_FACTORY, LiveUpdateModuleFactory.class);
+ if (moduleFactory != null) {
+ return moduleFactory.build(context, dataStore, config, privacyManager, airshipChannel, pushManager);
+ }
+ } catch (Exception e) {
+ Logger.error(e, "Failed to build Live Update module");
+ }
+ return null;
+ }
+
/**
* Creates the factory instance.
*
diff --git a/urbanairship-core/src/main/java/com/urbanairship/modules/liveupdate/LiveUpdateModuleFactory.java b/urbanairship-core/src/main/java/com/urbanairship/modules/liveupdate/LiveUpdateModuleFactory.java
new file mode 100644
index 000000000..dc8885db9
--- /dev/null
+++ b/urbanairship-core/src/main/java/com/urbanairship/modules/liveupdate/LiveUpdateModuleFactory.java
@@ -0,0 +1,32 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.modules.liveupdate;
+
+import android.content.Context;
+
+import com.urbanairship.AirshipVersionInfo;
+import com.urbanairship.PreferenceDataStore;
+import com.urbanairship.PrivacyManager;
+import com.urbanairship.channel.AirshipChannel;
+import com.urbanairship.config.AirshipRuntimeConfig;
+import com.urbanairship.modules.Module;
+import com.urbanairship.push.PushManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+
+/**
+ * Live Update module factory.
+ *
+ * @hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public interface LiveUpdateModuleFactory extends AirshipVersionInfo {
+ @NonNull
+ Module build(@NonNull Context context,
+ @NonNull PreferenceDataStore dataStore,
+ @NonNull AirshipRuntimeConfig config,
+ @NonNull PrivacyManager privacyManager,
+ @NonNull AirshipChannel airshipChannel,
+ @NonNull PushManager pushManager);
+}
diff --git a/urbanairship-core/src/main/java/com/urbanairship/push/PushMessage.java b/urbanairship-core/src/main/java/com/urbanairship/push/PushMessage.java
index c9f64a783..5f0a102cd 100644
--- a/urbanairship-core/src/main/java/com/urbanairship/push/PushMessage.java
+++ b/urbanairship-core/src/main/java/com/urbanairship/push/PushMessage.java
@@ -76,6 +76,12 @@ public class PushMessage implements Parcelable, JsonSerializable {
@NonNull
public static final String EXTRA_ACTIONS = "com.urbanairship.actions";
+ /**
+ * The Live Update payload.
+ */
+ @NonNull
+ public static final String EXTRA_LIVE_UPDATE = "com.urbanairship.live_update";
+
/**
* The extra key for the payload of Airship actions to be run when an
* interactive notification action button is opened.
@@ -692,6 +698,16 @@ public int getIcon(@NonNull Context context, int defaultIcon) {
return defaultIcon;
}
+ /**
+ * Gets the Live Update payload, if present.
+ *
+ * @return The Live Update payload or {@code null}, if not present.
+ */
+ @Nullable
+ public String getLiveUpdatePayload() {
+ return data.get(EXTRA_LIVE_UPDATE);
+ }
+
/**
* Returns the notification tag that should be used when posting the notification.
*
diff --git a/urbanairship-live-update/build.gradle b/urbanairship-live-update/build.gradle
new file mode 100644
index 000000000..5b3e06e71
--- /dev/null
+++ b/urbanairship-live-update/build.gradle
@@ -0,0 +1,42 @@
+plugins {
+ id 'airship-module'
+ id 'kotlin-android'
+ id 'kotlin-kapt'
+}
+
+description = "Airship Android Live Update extension."
+
+android {
+ namespace 'com.urbanairship.liveupdate'
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8
+ freeCompilerArgs = ["-Xexplicit-api=strict"]
+ }
+}
+
+dependencies {
+ api project(':urbanairship-core')
+
+ // Kotlin
+ implementation(libs.kotlinx.coroutines.android)
+
+ // AndroidX
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ kapt(libs.androidx.room.compiler)
+
+ // Tests
+ testImplementation project(':urbanairship-test')
+ testImplementation(libs.androidx.test.ext.junit)
+
+ testImplementation(libs.androidx.test.core)
+ testImplementation(libs.androidx.test.runner)
+ testImplementation(libs.androidx.test.rules)
+
+ testImplementation(libs.robolectric.core)
+ testImplementation(libs.mockk)
+ testImplementation(libs.androidx.room.testing)
+ testImplementation(libs.kotlinx.coroutines.test)
+ testImplementation(libs.turbine)
+}
diff --git a/urbanairship-live-update/proguard-rules.pro b/urbanairship-live-update/proguard-rules.pro
new file mode 100644
index 000000000..f1b424510
--- /dev/null
+++ b/urbanairship-live-update/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/urbanairship-live-update/schemas/com.urbanairship.liveupdate.data.LiveUpdateDatabase/1.json b/urbanairship-live-update/schemas/com.urbanairship.liveupdate.data.LiveUpdateDatabase/1.json
new file mode 100644
index 000000000..f5c70a8f1
--- /dev/null
+++ b/urbanairship-live-update/schemas/com.urbanairship.liveupdate.data.LiveUpdateDatabase/1.json
@@ -0,0 +1,90 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "dd75faf51b1c56afcc5d48421cf8193c",
+ "entities": [
+ {
+ "tableName": "live_update_state",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `type` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `last_start_stop_time` INTEGER NOT NULL, `dismissal_date` INTEGER, PRIMARY KEY(`name`))",
+ "fields": [
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isActive",
+ "columnName": "isActive",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "last_start_stop_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dismissalDate",
+ "columnName": "dismissal_date",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "name"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "live_update_content",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `content` TEXT NOT NULL, `last_update_time` INTEGER NOT NULL, PRIMARY KEY(`name`))",
+ "fields": [
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "content",
+ "columnName": "content",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "last_update_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "name"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dd75faf51b1c56afcc5d48421cf8193c')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/urbanairship-live-update/src/main/AndroidManifest.xml b/urbanairship-live-update/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..456800305
--- /dev/null
+++ b/urbanairship-live-update/src/main/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/AirshipDispatchers.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/AirshipDispatchers.kt
new file mode 100644
index 000000000..e44a4e1e3
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/AirshipDispatchers.kt
@@ -0,0 +1,25 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate
+
+import com.urbanairship.AirshipExecutors
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asCoroutineDispatcher
+
+/**
+ * Coroutine dispatchers using the Airship thread pools.
+ */
+internal object AirshipDispatchers {
+
+ /**
+ * Dispatcher that uses the full thread pool
+ */
+ val IO: CoroutineDispatcher = AirshipExecutors.threadPoolExecutor().asCoroutineDispatcher()
+
+ /**
+ * Creates a new single thread dispatcher.
+ */
+ fun newSingleThreadDispatcher(): CoroutineDispatcher {
+ return AirshipExecutors.newSerialExecutor().asCoroutineDispatcher()
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdate.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdate.kt
new file mode 100644
index 000000000..f7f9e60c8
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdate.kt
@@ -0,0 +1,51 @@
+package com.urbanairship.liveupdate
+
+import com.urbanairship.json.JsonMap
+import com.urbanairship.liveupdate.data.LiveUpdateContent
+import com.urbanairship.liveupdate.data.LiveUpdateState
+
+/**
+ * Information about a Live Update.
+ */
+public data class LiveUpdate(
+ /**
+ * The Live Update name.
+ */
+ public val name: String,
+
+ /**
+ * The Live Update type.
+ */
+ public val type: String,
+
+ /**
+ * The Live Update content.
+ */
+ public val content: JsonMap,
+
+ /**
+ * The timestamp of the last UPDATE event for this Live Update.
+ */
+ public val lastContentUpdateTime: Long,
+
+ /**
+ * The timestamp of the last START or STOP event for this Live Update.
+ */
+ public val lastStateChangeTime: Long,
+
+ /**
+ * The optional dismissal timestamp for this Live Update.
+ */
+ public val dismissalTime: Long? = null,
+) {
+ internal companion object {
+ internal fun from(state: LiveUpdateState, content: LiveUpdateContent) = LiveUpdate(
+ name = state.name,
+ type = state.type,
+ content = content.content,
+ lastContentUpdateTime = content.timestamp,
+ lastStateChangeTime = state.timestamp,
+ dismissalTime = state.dismissalDate
+ )
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateEvent.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateEvent.kt
new file mode 100644
index 000000000..b9a40cfd5
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateEvent.kt
@@ -0,0 +1,23 @@
+package com.urbanairship.liveupdate
+
+/** Live Update event types. */
+public enum class LiveUpdateEvent {
+ /** The Live Update was started. */
+ START,
+ /** The Live Update was stopped. */
+ END,
+ /** The Live Update content was updated. */
+ UPDATE;
+
+ internal companion object {
+ @Throws(IllegalArgumentException::class)
+ fun from(value: String): LiveUpdateEvent {
+ for (event in values()) {
+ if (event.name.equals(value, ignoreCase = true)) {
+ return event
+ }
+ }
+ throw IllegalArgumentException("Invalid Live Update event: $value")
+ }
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateHandler.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateHandler.kt
new file mode 100644
index 000000000..2b00c2fdd
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateHandler.kt
@@ -0,0 +1,73 @@
+package com.urbanairship.liveupdate
+
+import android.content.Context
+import androidx.core.app.NotificationCompat
+
+/** Handlers for Live Update events. */
+public sealed interface LiveUpdateHandler {
+ /**
+ * Called when a Live Update has been received.
+ *
+ * @return The [LiveUpdateResult].
+ */
+ public fun onUpdate(
+ /** Application `Context`. */
+ context: Context,
+ /** The Live Update [event][LiveUpdateEvent]. */
+ event: LiveUpdateEvent,
+ /** The Live Update data. */
+ update: LiveUpdate
+ ): LiveUpdateResult
+}
+
+/** Live Update handler that displays the latest content in a notification. */
+public interface LiveUpdateNotificationHandler : LiveUpdateHandler {
+ /**
+ * Called when a Live Update has been received.
+ *
+ * Implementations should return [LiveUpdateResult.ok] with a `NotificationCompat.Builder` to
+ * display the Live Update in a notification, or [LiveUpdateResult.cancel] to cancel the
+ * notification and stop Live Updates.
+ *
+ * An `ok` result with a `null` value will be ignored and will neither update nor cancel the
+ * existing notification.
+ *
+ * @return The [LiveUpdateResult].
+ */
+ public override fun onUpdate(
+ /** Application `Context`. */
+ context: Context,
+ /** The Live Update [event][LiveUpdateEvent]. */
+ event: LiveUpdateEvent,
+ /** The Live Update data. */
+ update: LiveUpdate
+ ): LiveUpdateResult
+}
+
+/** Live Update handler that allows for custom handling of Live Updates. */
+public interface LiveUpdateCustomHandler : LiveUpdateHandler
+
+/** Result type for [LiveUpdateHandler]s. */
+public sealed class LiveUpdateResult {
+ /** Successful result. */
+ public class Ok internal constructor(public val value: T? = null) : LiveUpdateResult()
+
+ /** Cancel result. */
+ public class Cancel internal constructor() : LiveUpdateResult()
+
+ public companion object {
+ /**
+ * Creates a new `LiveUpdateResult`, indicating that the Live Update was handled
+ * successfully.
+ */
+ @JvmStatic
+ @JvmOverloads
+ public fun ok(value: T? = null): LiveUpdateResult = Ok(value)
+
+ /**
+ * Indicates that the Live Update should be cancelled and updates stopped.
+ */
+ @JvmStatic
+ public fun cancel(): LiveUpdateResult = Cancel()
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateManager.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateManager.kt
new file mode 100644
index 000000000..4f1de98b6
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateManager.kt
@@ -0,0 +1,219 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate
+
+import android.content.Context
+import androidx.annotation.RestrictTo
+import androidx.annotation.VisibleForTesting
+import com.urbanairship.AirshipComponent
+import com.urbanairship.AirshipComponentGroups
+import com.urbanairship.Logger
+import com.urbanairship.PreferenceDataStore
+import com.urbanairship.PrivacyManager
+import com.urbanairship.PrivacyManager.FEATURE_PUSH
+import com.urbanairship.UAirship
+import com.urbanairship.channel.AirshipChannel
+import com.urbanairship.config.AirshipRuntimeConfig
+import com.urbanairship.job.JobInfo
+import com.urbanairship.job.JobResult
+import com.urbanairship.json.JsonMap
+import com.urbanairship.liveupdate.api.ChannelBulkUpdateApiClient
+import com.urbanairship.liveupdate.api.LiveUpdateMutation
+import com.urbanairship.liveupdate.data.LiveUpdateDatabase
+import com.urbanairship.liveupdate.notification.LiveUpdatePayload
+import com.urbanairship.push.PushListener
+import com.urbanairship.push.PushManager
+
+/**
+ * Airship Live Updates.
+ */
+public class LiveUpdateManager
+
+/** @hide */
+@VisibleForTesting
+internal constructor(
+ context: Context,
+ dataStore: PreferenceDataStore,
+ config: AirshipRuntimeConfig,
+ private val privacyManager: PrivacyManager,
+ private val pushManager: PushManager,
+ private val channel: AirshipChannel,
+ private val bulkUpdateClient: ChannelBulkUpdateApiClient = ChannelBulkUpdateApiClient(config),
+ db: LiveUpdateDatabase = LiveUpdateDatabase.createDatabase(context, config),
+ private val registrar: LiveUpdateRegistrar = LiveUpdateRegistrar(context, db.liveUpdateDao()),
+) : AirshipComponent(context, dataStore) {
+
+ private val isFeatureEnabled: Boolean
+ get() = privacyManager.isEnabled(FEATURE_PUSH) && channel.id != null
+
+ private val pushListener = PushListener { message, _ ->
+ message.liveUpdatePayload
+ ?.let { LiveUpdatePayload.fromJson(it) }
+ ?.let { registrar.onLiveUpdatePushReceived(message, it) }
+ }
+
+ public constructor(
+ context: Context,
+ dataStore: PreferenceDataStore,
+ config: AirshipRuntimeConfig,
+ privacyManager: PrivacyManager,
+ channel: AirshipChannel,
+ pushManager: PushManager
+ ) : this(context, dataStore, config, privacyManager, pushManager, channel)
+
+ /**
+ * Registers a [handler] for the given [type].
+ *
+ * @param type The handler type.
+ * @param handler A [LiveUpdateHandler].
+ */
+ public fun register(type: String, handler: LiveUpdateHandler<*>) {
+ registrar.register(type, handler)
+ }
+
+ /**
+ * Starts tracking for a Live Update, with initial [content].
+ *
+ * @param name The Live Update name.
+ * @param type The handler type.
+ * @param content A [JsonMap] with initial content.
+ * @param timestamp The start timestamp, used to filter out-of-order events (default: now).
+ * @param dismissTimestamp Optional timestamp, when to stop this Live Update (default: null).
+ */
+ @JvmOverloads
+ public fun start(
+ name: String,
+ type: String,
+ content: JsonMap,
+ timestamp: Long = System.currentTimeMillis(),
+ dismissTimestamp: Long? = null,
+ ) {
+ if (isFeatureEnabled) {
+ registrar.start(name, type, content, timestamp, dismissTimestamp)
+ }
+ }
+
+ /**
+ * Updates the [content] for a tracked Live Update.
+ *
+ * @param name The live update name.
+ * @param content A [JsonMap] with updated content.
+ * @param timestamp The update timestamp, used to filter out-of-order events (default: now).
+ */
+ @JvmOverloads
+ public fun update(
+ name: String,
+ content: JsonMap,
+ timestamp: Long = System.currentTimeMillis(),
+ dismissTimestamp: Long? = null,
+ ) {
+ if (isFeatureEnabled) {
+ registrar.update(name, content, timestamp, dismissTimestamp)
+ }
+ }
+
+ /**
+ * Stops tracking for the Live Update with the given [name].
+ *
+ * @param name The live update name.
+ * @param timestamp The stop timestamp, used to filter out-of-order events (default: now).
+ */
+ @JvmOverloads
+ public fun stop(
+ name: String,
+ content: JsonMap? = null,
+ timestamp: Long = System.currentTimeMillis(),
+ dismissTimestamp: Long? = null,
+ ) {
+ if (isFeatureEnabled) {
+ registrar.stop(name, content, timestamp, dismissTimestamp)
+ }
+ }
+
+ /** Stops tracking for all active Live Updates. */
+ public fun clearAll() {
+ if (isFeatureEnabled) {
+ registrar.clearAll()
+ }
+ }
+
+ /**
+ * Cancels the notification associated with the given Live Update [name].
+ *
+ * This will not stop tracking the Live Update and is a no-op for live updates that use custom
+ * handlers.
+ *
+ * @param name The live update name.
+ */
+ internal fun cancel(name: String) {
+ registrar.cancel(name)
+ }
+
+ /** @hide */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ override fun getComponentGroup(): Int = AirshipComponentGroups.LIVE_UPDATE
+
+ /** @hide */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public override fun init() {
+ super.init()
+
+ privacyManager.addListener { updateLiveActivityEnablement() }
+ updateLiveActivityEnablement()
+ }
+
+ override fun onPerformJob(airship: UAirship, jobInfo: JobInfo): JobResult {
+ return when (jobInfo.action) {
+ ACTION_UPDATE_CHANNEL ->
+ channel.id?.let { channelId ->
+ try {
+ val update = LiveUpdateMutation.fromJson(jobInfo.extras)
+ val resp = bulkUpdateClient.update(channelId, liveUpdates = listOf(update))
+ if (resp.isSuccessful) {
+ JobResult.SUCCESS
+ } else {
+ JobResult.RETRY
+ }
+ } catch (e: Throwable) {
+ Logger.error(e, "Failed to batch update channel for live update.")
+ JobResult.RETRY
+ }
+ } ?: run {
+ Logger.warn("Unable to update channel for live update. Channel ID is null.")
+ JobResult.RETRY
+ }
+ else -> {
+ Logger.debug("Unexpected job: $jobInfo")
+ JobResult.SUCCESS
+ }
+ }
+ }
+
+ /** @hide */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ override fun onComponentEnableChange(isEnabled: Boolean): Unit =
+ updateLiveActivityEnablement()
+
+ private fun updateLiveActivityEnablement() {
+ if (isFeatureEnabled) {
+ pushManager.addPushListener(pushListener)
+ } else {
+ // Clear all live updates.
+ registrar.clearAll()
+ pushManager.removePushListener(pushListener)
+ }
+ }
+
+ public companion object {
+ internal const val ACTION_UPDATE_CHANNEL = "ACTION_UPDATE_CHANNEL"
+
+ /**
+ * Gets the shared [LiveUpdateManager] instance.
+ *
+ * @return the shared instance of `LiveUpdateManager`.
+ */
+ @JvmStatic
+ public fun shared(): LiveUpdateManager =
+ UAirship.shared().requireComponent(LiveUpdateManager::class.java)
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateModuleFactoryImpl.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateModuleFactoryImpl.kt
new file mode 100644
index 000000000..64707cd6f
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateModuleFactoryImpl.kt
@@ -0,0 +1,46 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate
+
+import android.content.Context
+import androidx.annotation.RestrictTo
+import com.urbanairship.BuildConfig
+import com.urbanairship.PreferenceDataStore
+import com.urbanairship.PrivacyManager
+import com.urbanairship.channel.AirshipChannel
+import com.urbanairship.config.AirshipRuntimeConfig
+import com.urbanairship.modules.Module
+import com.urbanairship.modules.liveupdate.LiveUpdateModuleFactory
+import com.urbanairship.push.PushManager
+
+/**
+ * Live Update module factory implementation.
+ *
+ * @hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public class LiveUpdateModuleFactoryImpl : LiveUpdateModuleFactory {
+
+ override fun build(
+ context: Context,
+ dataStore: PreferenceDataStore,
+ config: AirshipRuntimeConfig,
+ privacyManager: PrivacyManager,
+ airshipChannel: AirshipChannel,
+ pushManager: PushManager
+ ): Module {
+ val manager = LiveUpdateManager(
+ context = context,
+ dataStore = dataStore,
+ config = config,
+ privacyManager = privacyManager,
+ channel = airshipChannel,
+ pushManager = pushManager
+ )
+ return Module.singleComponent(manager, 0)
+ }
+
+ override fun getAirshipVersion(): String = BuildConfig.AIRSHIP_VERSION
+
+ override fun getPackageVersion(): String = BuildConfig.SDK_VERSION
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateProcessor.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateProcessor.kt
new file mode 100644
index 000000000..73a3e1242
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateProcessor.kt
@@ -0,0 +1,288 @@
+package com.urbanairship.liveupdate
+
+import androidx.annotation.VisibleForTesting
+import com.urbanairship.Logger
+import com.urbanairship.json.JsonMap
+import com.urbanairship.liveupdate.api.LiveUpdateMutation
+import com.urbanairship.liveupdate.data.LiveUpdateContent
+import com.urbanairship.liveupdate.data.LiveUpdateDao
+import com.urbanairship.liveupdate.data.LiveUpdateState
+import com.urbanairship.push.PushMessage
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+
+internal class LiveUpdateProcessor(
+ private val dao: LiveUpdateDao,
+ dispatcher: CoroutineDispatcher = AirshipDispatchers.newSingleThreadDispatcher(),
+) {
+ private val scope: CoroutineScope = CoroutineScope(dispatcher + SupervisorJob())
+
+ private val callbacks = Channel(Channel.UNLIMITED)
+ val handlerCallbacks = callbacks.receiveAsFlow().flowOn(Dispatchers.Default)
+
+ internal data class NotificationCancel(val type: String, val name: String)
+
+ private val cancels = Channel(Channel.UNLIMITED)
+ val notificationCancels = cancels.receiveAsFlow().flowOn(Dispatchers.Default)
+
+ private val updates = Channel(Channel.UNLIMITED)
+ val channelUpdates = updates.receiveAsFlow().flowOn(Dispatchers.Default)
+
+ private var processJob: Job? = null
+ private val operationQueue = Channel(Channel.UNLIMITED)
+
+ @VisibleForTesting
+ internal val isProcessing: Boolean
+ get() = processJob?.isActive == true
+
+ internal fun enqueue(operation: Operation) {
+ operationQueue.trySend(operation)
+ tryStartProcessing()
+ }
+
+ private fun tryStartProcessing() {
+ // Bail out if we're already processing.
+ if (processJob != null && processJob?.isActive == true) {
+ return
+ }
+
+ // We don't check to see if there are any active Live Updates or registered handlers
+ // here, as we want to be able to process and store any incoming updates that may occur
+ // before a Live Update is started. This makes sure any out of order updates are still
+ // handled appropriately.
+
+ processJob = scope.launch {
+ Logger.verbose("Live Update processor started.")
+ for (operation in operationQueue) {
+ process(operation)
+ }
+ Logger.verbose("Live Update processor finished.")
+ }
+ }
+
+ private suspend fun tryStopProcessing() {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val isQueueEmpty = operationQueue.isEmpty
+
+ if (isQueueEmpty && !dao.isAnyActive()) {
+ processJob?.cancel()
+ processJob = null
+ Logger.verbose("Live Update processor stopped.")
+ }
+ }
+
+ private suspend fun process(operation: Operation) {
+ when (operation) {
+ is Operation.Start -> processStart(operation)
+ is Operation.Update -> processUpdate(operation)
+ is Operation.Stop -> processStop(operation)
+ is Operation.Cancel -> processCancel(operation)
+ is Operation.ClearAll -> processClearAll()
+ }
+ }
+
+ private suspend fun processStart(operation: Operation.Start): Unit = with(operation) {
+ val state = dao.getState(name)
+
+ // Check timestamp, as we may have received a stale start.
+ val lastTimestamp = state?.timestamp ?: 0
+ if (lastTimestamp > timestamp) {
+ Logger.warn("Ignored start for Live Update '$name'. Start event was stale.")
+ return
+ }
+
+ // If already started with a different type, process a stop and then re-start.
+ if (state?.isActive == true && state.type != type) {
+ enqueue(Operation.Stop(name = name, timestamp = timestamp))
+ enqueue(operation)
+ return
+ }
+
+ // If already started, ignore the start.
+ if (state?.isActive == true) {
+ Logger.warn("Ignored start for Live Update '$name'. Already started.")
+ return
+ }
+
+ val startedState = LiveUpdateState(
+ name = name,
+ type = type,
+ timestamp = timestamp,
+ dismissalDate = dismissalTimestamp,
+ isActive = true,
+ )
+
+ val startedContent = LiveUpdateContent(
+ name = name,
+ content = content,
+ timestamp = timestamp
+ )
+
+ dao.upsert(startedState, startedContent)
+
+ // Update Channel.
+ updates.trySend(LiveUpdateMutation.Set(name = name, startTime = timestamp))
+
+ // Notify handler of the start.
+ val update = LiveUpdate.from(startedState, startedContent)
+ callbacks.trySend(
+ HandlerCallback(LiveUpdateEvent.START, update, operation.message)
+ )
+ }
+
+ private suspend fun processUpdate(operation: Operation.Update): Unit = with(operation) {
+ val liveUpdate = dao.get(name)
+ val lastTimestamp = liveUpdate?.content?.timestamp ?: -1
+
+ if (lastTimestamp > timestamp) {
+ Logger.verbose("Ignoring stale Live Update content for '$name': $content")
+ return
+ }
+
+ // Update the dismissal date, if present in the payload.
+ val updatedState = liveUpdate?.state?.copy(
+ dismissalDate = dismissalTimestamp ?: liveUpdate.state.dismissalDate
+ )
+ val updateContent = LiveUpdateContent(name, content, timestamp)
+ dao.upsert(updatedState, updateContent)
+
+ // Notify handlers of the update if the update is started.
+ if (liveUpdate?.state?.isActive == true) {
+ val update = LiveUpdate.from(liveUpdate.state, updateContent)
+ callbacks.trySend(HandlerCallback(LiveUpdateEvent.UPDATE, update, operation.message))
+ } else {
+ Logger.warn("Ignoring Live Update for '$name'. Live Update is not started!")
+ }
+ }
+
+ private suspend fun processStop(operation: Operation.Stop): Unit = with(operation) {
+ try {
+ val liveUpdate = dao.get(name)
+ val lastState = liveUpdate?.state
+ val lastContent = liveUpdate?.content
+
+ if (lastState == null || lastContent == null || !lastState.isActive) {
+ Logger.warn("Ignored stop for Live Update '$name'. Live Update is not started!")
+ return
+ }
+
+ val lastTimestamp = lastState.timestamp
+ if (lastTimestamp > timestamp) {
+ Logger.verbose("Ignored stop for Live Update '$name'. Stop event was stale.")
+ return
+ }
+
+ val updatedState = lastState.copy(
+ isActive = false,
+ timestamp = timestamp,
+ dismissalDate = dismissalTimestamp ?: lastState.dismissalDate
+ )
+
+ val updatedContent = if (content != null) {
+ lastContent.copy(content = content, timestamp = timestamp)
+ } else {
+ lastContent
+ }
+
+ dao.upsert(updatedState, updatedContent)
+
+ // Update Channel.
+ updates.trySend(LiveUpdateMutation.Remove(name = name, startTime = lastTimestamp))
+
+ val updated = LiveUpdate.from(updatedState, updatedContent)
+
+ // Notify the handler of the stop, so it can handle cleaning up.
+ callbacks.trySend(HandlerCallback(LiveUpdateEvent.END, updated, operation.message))
+
+ // Clean up content.
+ dao.deleteContent(name)
+ } finally {
+ // Stop if there's nothing else to do at the moment.
+ tryStopProcessing()
+ }
+ }
+
+ private suspend fun processCancel(operation: Operation.Cancel) = with(operation) {
+ val state = dao.getState(name)
+ state?.let {
+ cancels.trySend(NotificationCancel(type = state.type, name = name))
+ }
+ }
+
+ private suspend fun processClearAll() {
+ // Notify handlers that we're stopping all tracked Live Updates.
+ dao.getAllActive()
+ .mapNotNull {
+ it.content?.let { content -> LiveUpdate.from(it.state, content) }
+ }
+ .forEach { update ->
+ callbacks.trySend(
+ HandlerCallback(LiveUpdateEvent.END, update, message = null)
+ )
+ }
+
+ // Clear all data.
+ dao.deleteAll()
+
+ // Stop.
+ tryStopProcessing()
+ }
+
+ @VisibleForTesting
+ internal sealed class Operation {
+ abstract val timestamp: Long
+
+ /** Start Live Updates for the given [name], and [type], with initial [content]. */
+ data class Start(
+ val name: String,
+ val type: String,
+ val content: JsonMap,
+ override val timestamp: Long,
+ val dismissalTimestamp: Long? = null,
+ val message: PushMessage? = null
+ ) : Operation()
+
+ /** Store updated [content] for the given [name]. */
+ data class Update(
+ val name: String,
+ val content: JsonMap,
+ override val timestamp: Long,
+ val dismissalTimestamp: Long? = null,
+ val message: PushMessage? = null
+ ) : Operation()
+
+ /** Stop Live Updates for the given [name]. */
+ data class Stop(
+ val name: String,
+ val content: JsonMap? = null,
+ override val timestamp: Long,
+ val dismissalTimestamp: Long? = null,
+ val message: PushMessage? = null
+ ) : Operation()
+
+ /** Cancels an existing Live Update notification for the given [name]. */
+ data class Cancel(
+ val name: String,
+ override val timestamp: Long = 0L
+ ) : Operation()
+
+ /** Clear all Live Updates and any locally stored data. */
+ data class ClearAll(
+ override val timestamp: Long = 0L
+ ) : Operation()
+ }
+
+ internal data class HandlerCallback(
+ val action: LiveUpdateEvent,
+ val update: LiveUpdate,
+ val message: PushMessage?
+ )
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateRegistrar.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateRegistrar.kt
new file mode 100644
index 000000000..dfb0ac7e1
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/LiveUpdateRegistrar.kt
@@ -0,0 +1,248 @@
+package com.urbanairship.liveupdate
+
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.VisibleForTesting
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import com.urbanairship.Logger
+import com.urbanairship.job.JobDispatcher
+import com.urbanairship.job.JobInfo
+import com.urbanairship.json.JsonMap
+import com.urbanairship.liveupdate.LiveUpdateProcessor.HandlerCallback
+import com.urbanairship.liveupdate.LiveUpdateProcessor.Operation
+import com.urbanairship.liveupdate.api.LiveUpdateMutation
+import com.urbanairship.liveupdate.data.LiveUpdateDao
+import com.urbanairship.liveupdate.notification.LiveUpdateNotificationReceiver
+import com.urbanairship.liveupdate.notification.LiveUpdatePayload
+import com.urbanairship.liveupdate.notification.NotificationTimeoutCompat
+import com.urbanairship.push.NotificationProxyActivity
+import com.urbanairship.push.PushManager
+import com.urbanairship.push.PushMessage
+import com.urbanairship.util.PendingIntentCompat
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
+
+/** Manages Live Update handlers and an operation queue to process Live Update events. */
+internal class LiveUpdateRegistrar(
+ private val context: Context,
+ dao: LiveUpdateDao,
+ dispatcher: CoroutineDispatcher = AirshipDispatchers.IO,
+ private val jobDispatcher: JobDispatcher = JobDispatcher.shared(context),
+ private val processor: LiveUpdateProcessor = LiveUpdateProcessor(dao),
+ private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(context),
+ private val notificationTimeoutCompat: NotificationTimeoutCompat = NotificationTimeoutCompat(context),
+) {
+ private val job = SupervisorJob()
+ private val scope: CoroutineScope = CoroutineScope(dispatcher + job)
+ @VisibleForTesting
+ internal val handlers = ConcurrentHashMap>()
+
+ init {
+ // Handle callbacks from the processor.
+ processor.handlerCallbacks
+ .onEach { handleCallback(it) }
+ .launchIn(scope)
+
+ // Handle notification cancel requests from the processor.
+ processor.notificationCancels
+ .onEach { cancelNotification(it.notificationTag) }
+ .launchIn(scope)
+
+ // Handle Channel updates from the processor.
+ processor.channelUpdates
+ .onEach { batchUpdateChannel(it) }
+ .launchIn(scope)
+ }
+
+ fun register(type: String, handler: LiveUpdateHandler<*>) {
+ handlers[type] = handler
+ }
+
+ fun start(
+ name: String,
+ type: String,
+ content: JsonMap,
+ timestamp: Long,
+ dismissalTimestamp: Long?,
+ message: PushMessage? = null
+ ) {
+ val handler = handlers[type]
+ if (handler == null) {
+ Logger.error("Can't start Live Update '$name'. No handler registered for type '$type'!")
+ return
+ }
+
+ processor.enqueue(
+ Operation.Start(
+ name = name,
+ type = type,
+ content = content,
+ timestamp = timestamp,
+ dismissalTimestamp = dismissalTimestamp,
+ message = message
+ )
+ )
+ }
+
+ fun update(
+ name: String,
+ content: JsonMap,
+ timestamp: Long,
+ dismissalTimestamp: Long?,
+ message: PushMessage? = null
+ ) = processor.enqueue(
+ Operation.Update(
+ name = name,
+ content = content,
+ timestamp = timestamp,
+ dismissalTimestamp = dismissalTimestamp,
+ message = message
+ )
+ )
+
+ fun stop(
+ name: String,
+ content: JsonMap?,
+ timestamp: Long,
+ dismissalTimestamp: Long?,
+ message: PushMessage? = null
+ ) = processor.enqueue(
+ Operation.Stop(
+ name = name,
+ content = content,
+ timestamp = timestamp,
+ dismissalTimestamp = dismissalTimestamp,
+ message = message
+ )
+ )
+
+ fun cancel(name: String, timestamp: Long = System.currentTimeMillis()) =
+ processor.enqueue(
+ Operation.Cancel(name = name, timestamp = timestamp)
+ )
+
+ fun clearAll(timestamp: Long = System.currentTimeMillis()) =
+ processor.enqueue(
+ Operation.ClearAll(timestamp = timestamp)
+ )
+
+ internal fun onLiveUpdatePushReceived(message: PushMessage, payload: LiveUpdatePayload) {
+ with(payload) {
+ when (event) {
+ LiveUpdateEvent.START -> if (type != null) {
+ start(name, type, content, timestamp, dismissalDate, message)
+ } else {
+ Logger.warn("Unable to start Live Update: $name. Missing required type!")
+ }
+ LiveUpdateEvent.END -> stop(name, content, timestamp, dismissalDate, message)
+ LiveUpdateEvent.UPDATE -> update(name, content, timestamp, dismissalDate, message)
+ }
+ }
+ }
+
+ private suspend fun handleCallback(callback: HandlerCallback) {
+ val (action, update, message) = callback
+ val type = update.type
+ val handler = handlers[type]
+ if (handler == null) {
+ Logger.error("No handler was registered to handle events for Live Update type: $type!")
+ return
+ }
+
+ val result = withContext(Dispatchers.Main) {
+ handler.onUpdate(context, action, update)
+ }
+
+ when (result) {
+ is LiveUpdateResult.Ok -> if (handler is LiveUpdateNotificationHandler) {
+ if (result.value is NotificationCompat.Builder) {
+ postNotification(context, update, result.value, message)
+ }
+ }
+ is LiveUpdateResult.Cancel -> cancelNotification(update.notificationTag)
+ }
+ }
+
+ private fun postNotification(
+ context: Context,
+ update: LiveUpdate,
+ builder: NotificationCompat.Builder,
+ message: PushMessage?
+ ) {
+ // Set dismissal time on the notification, if the live update specifies one.
+ update.dismissalTime?.let { dismissalTime ->
+ notificationTimeoutCompat.setTimeoutAt(builder, dismissalTime, update.name)
+ }
+
+ val notification = builder.build()
+
+ // If this live update event was triggered by a push, wrap the content intent so that we can
+ // launch the proxy activity to handle the push open.
+ if (message != null) {
+ val contentIntent = Intent(context, NotificationProxyActivity::class.java)
+ .setAction(PushManager.ACTION_NOTIFICATION_RESPONSE)
+ .addCategory(UUID.randomUUID().toString())
+ .putExtra(PushManager.EXTRA_PUSH_MESSAGE_BUNDLE, message.pushBundle)
+ .addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+ .putExtra(PushManager.EXTRA_NOTIFICATION_ID, NOTIFICATION_ID)
+ .putExtra(PushManager.EXTRA_NOTIFICATION_TAG, update.notificationTag)
+
+ // Store existing content intent, if present, so we can forward to it to the proxy activity.
+ notification.contentIntent?.let { original ->
+ contentIntent.putExtra(PushManager.EXTRA_NOTIFICATION_CONTENT_INTENT, original)
+ }
+ // Set our content intent.
+ notification.contentIntent = PendingIntentCompat.getActivity(context, 0, contentIntent, 0)
+ }
+
+ val deleteIntent = LiveUpdateNotificationReceiver.deleteIntent(context, update.name)
+ // Store existing delete intent, if there is one, so we can forward to it in our receiver.
+ notification.deleteIntent?.let { original ->
+ deleteIntent.putExtra(PushManager.EXTRA_NOTIFICATION_DELETE_INTENT, original)
+ }
+ // Set our delete intent.
+ notification.deleteIntent = PendingIntentCompat.getBroadcast(context, 0, deleteIntent, 0)
+
+ Logger.debug("Posting live update notification for: ${update.name}")
+
+ try {
+ notificationManager.notify(update.notificationTag, NOTIFICATION_ID, notification)
+ } catch (e: Exception) {
+ Logger.error(e, "Failed to post live update notification for: ${update.name}")
+ }
+ }
+
+ private fun cancelNotification(tag: String) =
+ notificationManager.cancel(tag, NOTIFICATION_ID)
+
+ private fun batchUpdateChannel(update: LiveUpdateMutation) = jobDispatcher.dispatch(
+ JobInfo.newBuilder()
+ .setAction(LiveUpdateManager.ACTION_UPDATE_CHANNEL)
+ .setAirshipComponent(LiveUpdateManager::class.java)
+ .setConflictStrategy(JobInfo.APPEND)
+ .setNetworkAccessRequired(true)
+ .setExtras(update.toJsonValue().optMap())
+ .build()
+ )
+
+ internal companion object {
+ @VisibleForTesting
+ internal const val NOTIFICATION_ID = 1010
+ }
+}
+
+private val LiveUpdate.notificationTag: String
+ get() = notificationTag(type, name)
+
+private val LiveUpdateProcessor.NotificationCancel.notificationTag: String
+ get() = notificationTag(type, name)
+
+private fun notificationTag(type: String, name: String) = "$type:$name"
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClient.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClient.kt
new file mode 100644
index 000000000..0b7fa9eb4
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClient.kt
@@ -0,0 +1,164 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.api
+
+import android.net.Uri
+import androidx.annotation.VisibleForTesting
+import com.urbanairship.Logger
+import com.urbanairship.UAirship.AMAZON_PLATFORM
+import com.urbanairship.UAirship.ANDROID_PLATFORM
+import com.urbanairship.channel.AttributeMutation
+import com.urbanairship.channel.SubscriptionListMutation
+import com.urbanairship.channel.TagGroupsMutation
+import com.urbanairship.config.AirshipRuntimeConfig
+import com.urbanairship.http.RequestException
+import com.urbanairship.http.RequestFactory
+import com.urbanairship.http.Response
+import com.urbanairship.json.JsonException
+import com.urbanairship.json.JsonMap
+import com.urbanairship.json.JsonSerializable
+import com.urbanairship.json.JsonValue
+import com.urbanairship.liveupdate.util.jsonMapOf
+import com.urbanairship.liveupdate.util.requireField
+import com.urbanairship.liveupdate.util.toJsonList
+import com.urbanairship.util.Clock
+import com.urbanairship.util.UAHttpStatusUtil
+
+/** API client for the channel bulk update endpoint. */
+internal class ChannelBulkUpdateApiClient(
+ private val config: AirshipRuntimeConfig,
+ private val requestFactory: RequestFactory = RequestFactory.DEFAULT_REQUEST_FACTORY
+) {
+ /** Bulk update channel subscription lists, tags, and attributes. */
+ @Throws(RequestException::class)
+ fun update(
+ channelId: String,
+ tags: List? = null,
+ attributes: List? = null,
+ subscriptions: List? = null,
+ liveUpdates: List? = null,
+ ): Response {
+ val payload =
+ ChannelBulkUpdateRequest(channelId, tags, attributes, subscriptions, liveUpdates)
+ Logger.verbose("Bulk updating channel ($channelId) with payload: ${payload.toJsonValue()}")
+
+ return requestFactory.createRequest()
+ .setOperation("PUT", bulkUpdateUrl(channelId))
+ .setCredentials(config.configOptions.appKey, config.configOptions.appSecret)
+ .setRequestBody(payload)
+ .setAirshipJsonAcceptsHeader()
+ .setAirshipUserAgent(config)
+ .execute { status, _, _ ->
+ if (!UAHttpStatusUtil.inSuccessRange(status)) {
+ throw RequestException("Unexpected response status: $status")
+ }
+ }
+ }
+
+ private fun bulkUpdateUrl(channelId: String): Uri? {
+ val builder = config.urlConfig.deviceUrl()
+ .appendEncodedPath(CHANNEL_BULK_UPDATE_PATH)
+ .appendPath(channelId)
+
+ platform?.let {
+ builder.appendQueryParameter(PLATFORM_PARAM, it)
+ }
+
+ return builder.build()
+ }
+
+ private val platform = when (config.platform) {
+ ANDROID_PLATFORM -> PLATFORM_ANDROID
+ AMAZON_PLATFORM -> PLATFORM_AMAZON
+ else -> null
+ }
+
+ private companion object {
+ private const val CHANNEL_BULK_UPDATE_PATH = "api/channels/sdk/batch"
+ private const val PLATFORM_ANDROID = "android"
+ private const val PLATFORM_AMAZON = "amazon"
+ private const val PLATFORM_PARAM = "platform"
+ }
+}
+
+@VisibleForTesting
+internal data class ChannelBulkUpdateRequest @JvmOverloads constructor (
+ val channelId: String,
+ val tagGroups: List? = null,
+ val attributes: List? = null,
+ val subscriptionLists: List? = null,
+ val liveUpdates: List? = null
+) : JsonSerializable {
+ override fun toJsonValue(): JsonValue = JsonMap.newBuilder().apply {
+ tagGroups?.let { tags ->
+ put(TAGS, TagGroupsMutation.collapseMutations(tags).toJsonList())
+ }
+ attributes?.let { attrs ->
+ put(ATTRIBUTES, AttributeMutation.collapseMutations(attrs).toJsonList())
+ }
+ subscriptionLists?.let { lists ->
+ put(SUBSCRIPTION_LISTS, SubscriptionListMutation.collapseMutations(lists).toJsonList())
+ }
+ liveUpdates?.let { updates ->
+ put(LIVE_UPDATES, updates.map { it.toJsonValue() }.toJsonList())
+ }
+ }.build().toJsonValue()
+
+ private companion object {
+ private const val TAGS = "tags"
+ private const val ATTRIBUTES = "attributes"
+ private const val SUBSCRIPTION_LISTS = "subscription_lists"
+ private const val LIVE_UPDATES = "live_updates"
+ }
+}
+
+internal sealed class LiveUpdateMutation(
+ val action: String,
+) : JsonSerializable {
+ abstract val name: String
+ abstract val startTime: Long
+ abstract val actionTime: Long
+
+ internal class Set(
+ override val name: String,
+ override val startTime: Long,
+ override val actionTime: Long = Clock.DEFAULT_CLOCK.currentTimeMillis()
+
+ ) : LiveUpdateMutation(ACTION_SET)
+ internal class Remove(
+ override val name: String,
+ override val startTime: Long,
+ override val actionTime: Long = Clock.DEFAULT_CLOCK.currentTimeMillis()
+ ) : LiveUpdateMutation(ACTION_REMOVE)
+
+ override fun toJsonValue(): JsonValue =
+ jsonMapOf(
+ KEY_ACTION to action,
+ KEY_NAME to name,
+ KEY_START_TS to startTime,
+ KEY_ACTION_TS to actionTime
+ ).toJsonValue()
+
+ internal companion object {
+ private const val KEY_ACTION = "action"
+ private const val KEY_NAME = "name"
+ private const val KEY_START_TS = "start_ts_ms"
+ private const val KEY_ACTION_TS = "action_ts_ms"
+ private const val ACTION_SET = "set"
+ private const val ACTION_REMOVE = "remove"
+
+ @Throws(JsonException::class)
+ fun fromJson(json: JsonMap): LiveUpdateMutation {
+ val action: String = json.requireField(KEY_ACTION)
+ val name: String = json.requireField(KEY_NAME)
+ val startTime: Long = json.requireField(KEY_START_TS)
+ val actionTime: Long = json.requireField(KEY_ACTION_TS)
+
+ return when (action) {
+ ACTION_SET -> Set(name, startTime, actionTime)
+ ACTION_REMOVE -> Remove(name, startTime, actionTime)
+ else -> throw JsonException("Failed to parse LiveUpdateMutation json: $json")
+ }
+ }
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/Converters.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/Converters.kt
new file mode 100644
index 000000000..131629fa5
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/Converters.kt
@@ -0,0 +1,20 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.data
+
+import androidx.room.TypeConverter
+import com.urbanairship.json.JsonMap
+import com.urbanairship.json.JsonValue
+
+/**
+ * @hide
+ */
+internal class Converters {
+ @TypeConverter
+ fun fromJsonMap(value: JsonMap): String = value.toString()
+
+ @TypeConverter
+ fun toJsonMap(value: String): JsonMap {
+ return JsonValue.parseString(value).requireMap()
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDao.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDao.kt
new file mode 100644
index 000000000..63cb38d63
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDao.kt
@@ -0,0 +1,87 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.data
+
+import androidx.annotation.RestrictTo
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Upsert
+
+/**
+ * Live Update DAO.
+ * @hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@Dao
+internal interface LiveUpdateDao {
+
+ @Transaction
+ @Upsert
+ suspend fun upsert(state: LiveUpdateState)
+
+ @Transaction
+ @Upsert
+ suspend fun upsert(content: LiveUpdateContent)
+
+ @Transaction
+ suspend fun upsert(state: LiveUpdateState? = null, content: LiveUpdateContent? = null) {
+ state?.let { upsert(it) }
+ content?.let { upsert(it) }
+ }
+
+ @Transaction
+ @Query("SELECT * FROM live_update_state WHERE name = :name LIMIT 1")
+ suspend fun get(name: String): LiveUpdateStateWithContent?
+
+ @Transaction
+ @Query("SELECT * FROM live_update_state WHERE name = :name LIMIT 1")
+ suspend fun getState(name: String): LiveUpdateState?
+
+ @Transaction
+ @Query("SELECT * FROM live_update_content WHERE name = :name LIMIT 1")
+ suspend fun getContent(name: String): LiveUpdateContent?
+
+ @Transaction
+ @Query("SELECT * FROM live_update_state WHERE isActive = 1 " +
+ "AND ((dismissal_date IS NULL) OR (dismissal_date >= strftime('%s', 'now') * 1000))")
+ suspend fun getAllActive(): List
+
+ @Transaction
+ @Query("DELETE FROM live_update_state WHERE name = :name")
+ suspend fun deleteState(name: String)
+
+ @Transaction
+ @Query("DELETE FROM live_update_content WHERE name = :name")
+ suspend fun deleteContent(name: String)
+
+ @Transaction
+ suspend fun delete(name: String) {
+ deleteState(name)
+ deleteContent(name)
+ }
+
+ @Transaction
+ @Query("DELETE FROM live_update_state")
+ suspend fun deleteAllState()
+
+ @Transaction
+ @Query("DELETE FROM live_update_content")
+ suspend fun deleteAllContent()
+
+ @Transaction
+ suspend fun deleteAll() {
+ deleteAllState()
+ deleteAllContent()
+ }
+
+ @Query("SELECT COUNT(*) > 0 FROM live_update_state WHERE isActive = 1 " +
+ "AND ((dismissal_date IS NULL) OR (dismissal_date >= strftime('%s', 'now') * 1000))")
+ suspend fun isAnyActive(): Boolean
+
+ @Query("SELECT COUNT(*) FROM live_update_state")
+ suspend fun countState(): Int
+
+ @Query("SELECT COUNT(*) FROM live_update_content")
+ suspend fun countContent(): Int
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDatabase.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDatabase.kt
new file mode 100644
index 000000000..1db9343d5
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateDatabase.kt
@@ -0,0 +1,43 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.data
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.core.content.ContextCompat
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import com.urbanairship.config.AirshipRuntimeConfig
+import java.io.File
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.asExecutor
+
+@Database(
+ version = 1,
+ entities = [LiveUpdateState::class, LiveUpdateContent::class]
+)
+@TypeConverters(Converters::class)
+internal abstract class LiveUpdateDatabase : RoomDatabase() {
+
+ abstract fun liveUpdateDao(): LiveUpdateDao
+
+ companion object {
+ fun createDatabase(context: Context, config: AirshipRuntimeConfig): LiveUpdateDatabase {
+ val name = config.configOptions.appKey + "_live_updates"
+ val path = File(ContextCompat.getNoBackupFilesDir(context), name).absolutePath
+ return Room.databaseBuilder(context, LiveUpdateDatabase::class.java, path)
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+
+ @VisibleForTesting
+ internal fun createInMemoryDatabase(context: Context, dispatcher: CoroutineDispatcher): LiveUpdateDatabase =
+ Room.inMemoryDatabaseBuilder(context, LiveUpdateDatabase::class.java)
+ .allowMainThreadQueries()
+ .setTransactionExecutor(dispatcher.asExecutor())
+ .setQueryExecutor(dispatcher.asExecutor())
+ .build()
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateEntities.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateEntities.kt
new file mode 100644
index 000000000..75c0c38ba
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/data/LiveUpdateEntities.kt
@@ -0,0 +1,42 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.data
+
+import androidx.room.ColumnInfo
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.Relation
+import com.urbanairship.json.JsonMap
+
+@Entity(tableName = "live_update_state")
+internal data class LiveUpdateState(
+ @PrimaryKey
+ val name: String,
+ val type: String,
+ val isActive: Boolean,
+ /** Timestamp of the last START or STOP event for this Live Update. */
+ @ColumnInfo(name = "last_start_stop_time")
+ val timestamp: Long,
+ /** Optional timestamp, to auto-dismiss the Live Update. */
+ @ColumnInfo(name = "dismissal_date")
+ val dismissalDate: Long? = null
+)
+
+@Entity(tableName = "live_update_content")
+internal data class LiveUpdateContent(
+ @PrimaryKey
+ val name: String,
+ val content: JsonMap,
+ /** Timestamp of the last UPDATE event for this Live Update. */
+ @ColumnInfo(name = "last_update_time")
+ val timestamp: Long
+)
+
+/** Wrapper data class representing a Live Update's state, joined with the latest content. */
+internal data class LiveUpdateStateWithContent(
+ @Embedded
+ val state: LiveUpdateState,
+ @Relation(parentColumn = "name", entityColumn = "name")
+ val content: LiveUpdateContent?
+)
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdateNotificationReceiver.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdateNotificationReceiver.kt
new file mode 100644
index 000000000..efe608e3b
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdateNotificationReceiver.kt
@@ -0,0 +1,73 @@
+package com.urbanairship.liveupdate.notification
+
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import com.urbanairship.Logger
+import com.urbanairship.liveupdate.LiveUpdateManager
+import com.urbanairship.push.PushManager.EXTRA_NOTIFICATION_DELETE_INTENT
+
+/** Receiver for Live Update notifications. */
+public class LiveUpdateNotificationReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent): Unit = with(intent) {
+ val name = getStringExtra(EXTRA_ACTIVITY_NAME)
+ if (name == null) {
+ // This shouldn't happen.
+ Logger.error("Received Live Update notification broadcast without a name!")
+ return
+ }
+
+ // Handle the broadcast.
+ when (action) {
+ ACTION_NOTIFICATION_DISMISSED -> {
+ // Stop updates for this live activity.
+ LiveUpdateManager.shared().stop(name)
+ Logger.verbose("Stopped live updates for: $name")
+ }
+ ACTION_NOTIFICATION_TIMEOUT -> {
+ // Stop updates for this live activity and cancel the notification, if one exists.
+ LiveUpdateManager.shared().stop(name)
+ LiveUpdateManager.shared().cancel(name)
+ Logger.verbose("Timed out live updates for: $name")
+ }
+ else -> Logger.warn("Received unknown Live Update broadcast: $action")
+ }
+
+ // Call through to the original delete intent, if one was provided.
+ getParcelableExtraCompat(EXTRA_NOTIFICATION_DELETE_INTENT)?.let { intent ->
+ try {
+ intent.send()
+ } catch (e: PendingIntent.CanceledException) {
+ Logger.debug("Failed to send notification's deleteIntent, already canceled.")
+ }
+ }
+ }
+
+ internal companion object {
+ private const val ACTION_NOTIFICATION_DISMISSED =
+ "com.urbanairship.liveupdate.NOTIFICATION_DISMISSED"
+ private const val ACTION_NOTIFICATION_TIMEOUT =
+ "com.urbanairship.liveupdate.NOTIFICATION_TIMEOUT"
+
+ private const val EXTRA_ACTIVITY_NAME: String = "activity_name"
+
+ internal fun deleteIntent(context: Context, name: String): Intent =
+ Intent(context, LiveUpdateNotificationReceiver::class.java)
+ .setAction(ACTION_NOTIFICATION_DISMISSED)
+ .putExtra(EXTRA_ACTIVITY_NAME, name)
+ .addCategory(name)
+
+ internal fun timeoutCompatIntent(context: Context, name: String): Intent =
+ Intent(context, LiveUpdateNotificationReceiver::class.java)
+ .setAction(ACTION_NOTIFICATION_TIMEOUT)
+ .putExtra(EXTRA_ACTIVITY_NAME, name)
+ .addCategory(name)
+ }
+}
+
+private inline fun Intent.getParcelableExtraCompat(key: String): T? = when {
+ Build.VERSION.SDK_INT >= 33 -> getParcelableExtra(key, T::class.java)
+ else -> @Suppress("DEPRECATION") getParcelableExtra(key) as? T
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdatePayload.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdatePayload.kt
new file mode 100644
index 000000000..1c435fae7
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/LiveUpdatePayload.kt
@@ -0,0 +1,66 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.notification
+
+import com.urbanairship.Logger
+import com.urbanairship.json.JsonMap
+import com.urbanairship.json.JsonValue
+import com.urbanairship.liveupdate.LiveUpdateEvent
+import com.urbanairship.liveupdate.util.optionalField
+import com.urbanairship.liveupdate.util.requireField
+
+/**
+ * Live Update push payload.
+ *
+ * Contains metadata and `content` for a Live Update push.
+ * @hide
+ */
+internal data class LiveUpdatePayload(
+ /** Unique name for the Live Update. */
+ val name: String,
+ /** Live Update event type. */
+ val event: LiveUpdateEvent,
+ /** Live Update type. */
+ val type: String?,
+ /** Scheduled dismiss date, in ms. */
+ val dismissalDate: Long?,
+ /** The timestamp for this update, in ms. */
+ val timestamp: Long,
+ /** Live Update content. */
+ val content: JsonMap
+) {
+ internal companion object {
+ internal fun fromJson(json: String): LiveUpdatePayload? =
+ try {
+ JsonValue.parseString(json).map?.let { fromJson(it) }
+ } catch (e: Exception) {
+ Logger.warn(e, "Failed to parse live update payload: $json")
+ null
+ }
+
+ private fun fromJson(json: JsonMap): LiveUpdatePayload {
+ val content = json.opt("content_state").let { contentState ->
+ when {
+ // If the content state is a map, use it directly.
+ contentState.isJsonMap -> contentState.optMap()
+ // Otherwise, try parsing as a json string.
+ contentState.isString -> JsonValue.parseString(contentState.string).optMap()
+ // Invalid content.
+ else -> {
+ Logger.warn("Invalid Live Update content_state: '$contentState'")
+ JsonMap.EMPTY_MAP
+ }
+ }
+ }
+
+ return LiveUpdatePayload(
+ name = json.requireField("name"),
+ event = json.requireField("event").let { LiveUpdateEvent.from(it) },
+ type = json.optionalField("type"),
+ dismissalDate = json.optionalField("dismissal_date")?.let { it * 1000 },
+ timestamp = json.requireField("timestamp") * 1000,
+ content = content
+ )
+ }
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/NotificationTimeoutCompat.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/NotificationTimeoutCompat.kt
new file mode 100644
index 000000000..42145d34b
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/notification/NotificationTimeoutCompat.kt
@@ -0,0 +1,48 @@
+package com.urbanairship.liveupdate.notification
+
+import android.app.AlarmManager
+import android.content.Context
+import android.os.Build
+import androidx.core.app.AlarmManagerCompat
+import androidx.core.app.NotificationCompat
+import com.urbanairship.UAirship
+import com.urbanairship.util.Clock
+import com.urbanairship.util.PendingIntentCompat
+
+/**
+ * Compat helper that sets the timeout for a notification.
+ *
+ * On O+, the timeout is set using [NotificationCompat.Builder.setTimeoutAfter].
+ * On earlier versions, an alarm is set to dismiss the notification.
+ */
+internal class NotificationTimeoutCompat(
+ private val context: Context = UAirship.getApplicationContext(),
+ private val clock: Clock = Clock.DEFAULT_CLOCK,
+) {
+ private val alarmManager: AlarmManager by lazy {
+ context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ }
+
+ internal fun setTimeoutAt(
+ builder: NotificationCompat.Builder,
+ timeoutAt: Long,
+ name: String
+ ): NotificationCompat.Builder {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // Use the NotificationCompat.Builder APIs to set the timeout, if we're on O+.
+ val timeoutAfter = timeoutAt - clock.currentTimeMillis()
+ builder.setTimeoutAfter(timeoutAfter)
+ } else {
+ // Otherwise, fall back to setting an alarm to dismiss the notification.
+ setTimeoutAlarm(name, timeoutAt)
+ }
+ return builder
+ }
+
+ private fun setTimeoutAlarm(name: String, timeoutAt: Long) {
+ val intent = LiveUpdateNotificationReceiver.timeoutCompatIntent(context, name)
+ val operation = PendingIntentCompat.getBroadcast(context, 0, intent, 0)
+
+ AlarmManagerCompat.setExact(alarmManager, AlarmManager.RTC, timeoutAt, operation)
+ }
+}
diff --git a/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/util/JsonExtensions.kt b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/util/JsonExtensions.kt
new file mode 100644
index 000000000..96f9ae35d
--- /dev/null
+++ b/urbanairship-live-update/src/main/java/com/urbanairship/liveupdate/util/JsonExtensions.kt
@@ -0,0 +1,69 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.util
+
+import com.urbanairship.json.JsonException
+import com.urbanairship.json.JsonList
+import com.urbanairship.json.JsonMap
+import com.urbanairship.json.JsonSerializable
+import com.urbanairship.json.JsonValue
+
+@Throws(JsonException::class)
+internal fun jsonMapOf(vararg fields: Pair): JsonMap =
+ JsonMap.newBuilder().apply {
+ for ((k, v) in fields) {
+ put(k, JsonValue.wrap(v))
+ }
+ }.build()
+
+@Throws(JsonException::class)
+internal fun jsonListOf(vararg values: Any): JsonList = JsonList(values.map(JsonValue::wrap))
+
+internal fun List.toJsonList(): JsonList = JsonList(this.map { it.toJsonValue() })
+
+internal val JsonList.isNotEmpty: Boolean
+ get() = !isEmpty
+
+internal fun Map.toJsonMap(): JsonMap = JsonMap(this)
+
+/**
+ * Gets the field with the given [key] from the [JsonMap], ensuring it is non-null.
+ *
+ * @throws JsonException if an invalid type is specified, or if the field is `null` or missing.
+ */
+@Throws(JsonException::class)
+internal inline fun JsonMap.requireField(key: String): T {
+ val field = get(key) ?: throw JsonException("Missing required field: '$key'")
+ return when (T::class) {
+ String::class -> field.optString() as T
+ Boolean::class -> field.getBoolean(false) as T
+ Long::class -> field.getLong(0) as T
+ Double::class -> field.getDouble(0.0) as T
+ Integer::class -> field.getInt(0) as T
+ JsonList::class -> field.optList() as T
+ JsonMap::class -> field.optMap() as T
+ JsonValue::class -> field.toJsonValue() as T
+ else -> throw JsonException("Invalid type '${T::class.java.simpleName}' for field '$key'")
+ }
+}
+
+/**
+ * Gets the field with the given [key] from the [JsonMap], or `null` if not defined.
+ *
+ * @throws JsonException if an invalid type is specified.
+ */
+@Throws(JsonException::class)
+internal inline fun JsonMap.optionalField(key: String): T? {
+ val field = get(key) ?: return null
+ return when (T::class) {
+ String::class -> field.optString() as T
+ Boolean::class -> field.getBoolean(false) as T
+ Long::class -> field.getLong(0) as T
+ Double::class -> field.getDouble(0.0) as T
+ Integer::class -> field.getInt(0) as T
+ JsonList::class -> field.optList() as T
+ JsonMap::class -> field.optMap() as T
+ JsonValue::class -> field.toJsonValue() as T
+ else -> throw JsonException("Invalid type '${T::class.java.simpleName}' for field '$key'")
+ }
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateManagerTest.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateManagerTest.kt
new file mode 100644
index 000000000..b9cac910b
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateManagerTest.kt
@@ -0,0 +1,66 @@
+package com.urbanairship.liveupdate
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.urbanairship.PreferenceDataStore
+import com.urbanairship.PrivacyManager
+import com.urbanairship.TestApplication
+import com.urbanairship.UAirship.ANDROID_PLATFORM
+import com.urbanairship.channel.AirshipChannel
+import com.urbanairship.config.AirshipRuntimeConfig
+import com.urbanairship.liveupdate.data.LiveUpdateDao
+import com.urbanairship.liveupdate.data.LiveUpdateDatabase
+import com.urbanairship.push.PushManager
+import io.mockk.every
+import io.mockk.mockk
+import junit.framework.TestCase.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+public class LiveUpdateManagerTest {
+
+ private val config: AirshipRuntimeConfig = mockk {
+ every { configOptions } returns mockk()
+ every { platform } returns ANDROID_PLATFORM
+ }
+ private val pushManager: PushManager = mockk(relaxed = true)
+ private val channel: AirshipChannel = mockk {
+ every { id } returns "channelId"
+ }
+ private val dao: LiveUpdateDao = mockk()
+ private val database: LiveUpdateDatabase = mockk {
+ every { liveUpdateDao() } returns dao
+ }
+ private val registrar: LiveUpdateRegistrar = mockk()
+
+ private lateinit var dataStore: PreferenceDataStore
+ private lateinit var privacyManager: PrivacyManager
+
+ private lateinit var liveUpdateManager: LiveUpdateManager
+
+ @Before
+ public fun setUp() {
+ val context = InstrumentationRegistry.getInstrumentation().context
+ dataStore = PreferenceDataStore.inMemoryStore(TestApplication.getApplication())
+ privacyManager = PrivacyManager(dataStore, PrivacyManager.FEATURE_ALL)
+
+ liveUpdateManager = LiveUpdateManager(
+ context = TestApplication.getApplication(),
+ dataStore = dataStore,
+ config = config,
+ privacyManager = privacyManager,
+ channel = channel,
+ pushManager = pushManager,
+ db = database,
+ registrar = registrar
+ )
+ }
+
+ @Test
+ public fun testInit() {
+ liveUpdateManager.init()
+ assertTrue(liveUpdateManager.isComponentEnabled)
+ }
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateProcessorTest.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateProcessorTest.kt
new file mode 100644
index 000000000..6c56d8e21
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateProcessorTest.kt
@@ -0,0 +1,431 @@
+package com.urbanairship.liveupdate
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import app.cash.turbine.test
+import com.urbanairship.liveupdate.LiveUpdateProcessor.Operation
+import com.urbanairship.liveupdate.data.LiveUpdateContent
+import com.urbanairship.liveupdate.data.LiveUpdateDao
+import com.urbanairship.liveupdate.data.LiveUpdateState
+import com.urbanairship.liveupdate.data.LiveUpdateStateWithContent
+import com.urbanairship.liveupdate.util.jsonMapOf
+import io.mockk.clearMocks
+import io.mockk.coEvery
+import io.mockk.coVerifyOrder
+import io.mockk.coVerifySequence
+import io.mockk.mockk
+import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertTrue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+public class LiveUpdateProcessorTest {
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val dao: LiveUpdateDao = mockk(relaxed = true, relaxUnitFun = true)
+
+ private lateinit var processor: LiveUpdateProcessor
+
+ @Before
+ public fun setUp() {
+ processor = LiveUpdateProcessor(dao, testDispatcher)
+ }
+
+ @Test
+ public fun testStart(): TestResult = runTest(testDispatcher) {
+ val content = jsonMapOf("foo" to "bar")
+
+ coEvery { dao.getState(eq("name")) } returns LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = 0,
+ dismissalDate = null,
+ isActive = false
+ )
+
+ processor.handlerCallbacks.test {
+ processor.enqueue(
+ Operation.Start(
+ name = "name",
+ type = "type",
+ content = content,
+ timestamp = 0,
+ dismissalTimestamp = null
+ )
+ )
+ println("enqueued...")
+
+ awaitItem().let {
+ println("gotItem = $it")
+
+ assertTrue(it.action == LiveUpdateEvent.START)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ assertEquals(content, it.update.content)
+ }
+
+ coVerifySequence {
+ dao.getState(eq("name"))
+ dao.upsert(
+ eq(
+ LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = 0,
+ dismissalDate = null,
+ isActive = true
+ )
+ ),
+ eq(
+ LiveUpdateContent(
+ name = "name",
+ content = content,
+ timestamp = 0,
+ )
+ )
+ )
+ }
+
+ ensureAllEventsConsumed()
+ }
+ }
+
+ @Test
+ public fun testUpdateVerifiesTimestamps(): TestResult = runTest(testDispatcher) {
+ fun testContent(value: Int, timestamp: Long) = LiveUpdateContent(
+ name = "name",
+ content = jsonMapOf("foo" to value),
+ timestamp = timestamp
+ )
+ val initialContent = testContent(0, 0L)
+ val staleContent = testContent(2, 50L)
+ val updatedContent = testContent(3, 100L)
+
+ // Initial mocks
+ coEvery { dao.get(eq("name")) } returns LiveUpdateStateWithContent(
+ LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = initialContent.timestamp,
+ dismissalDate = null,
+ isActive = true
+ ),
+ LiveUpdateContent(
+ name = "name",
+ content = initialContent.content,
+ timestamp = initialContent.timestamp
+ )
+ )
+
+ processor.handlerCallbacks.test {
+ // Enqueue initial update
+ processor.enqueue(
+ Operation.Update(
+ updatedContent.name,
+ updatedContent.content,
+ updatedContent.timestamp
+ )
+ )
+
+ awaitItem().let {
+ assertTrue(it.action == LiveUpdateEvent.UPDATE)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ assertEquals(updatedContent.content, it.update.content)
+ }
+
+ advanceUntilIdle()
+ ensureAllEventsConsumed()
+
+ coVerifySequence {
+ dao.get(eq("name"))
+ dao.upsert(state = any(), content = eq(updatedContent))
+ }
+
+ // Reset mocks to reflect the updated content/timestamp
+ clearMocks(dao)
+ coEvery { dao.get(eq("name")) } returns LiveUpdateStateWithContent(
+ LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = initialContent.timestamp,
+ dismissalDate = null,
+ isActive = true
+ ),
+ LiveUpdateContent(
+ name = "name",
+ content = updatedContent.content,
+ timestamp = updatedContent.timestamp
+ )
+ )
+
+ // Enqueue stale update
+ processor.enqueue(Operation.Update("name", staleContent.content, staleContent.timestamp))
+
+ advanceUntilIdle()
+ ensureAllEventsConsumed()
+
+ // Ensure we read the existing record from the db (to get the timestamp)
+ coVerifySequence {
+ dao.get(eq("name"))
+ }
+ }
+ }
+
+ @Test
+ public fun testHandleUpdate(): TestResult = runTest(testDispatcher) {
+ val initial = jsonMapOf("foo" to "bar")
+ val updated = jsonMapOf("fizz" to "buzz")
+
+ val initialState = LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = 0,
+ dismissalDate = null,
+ isActive = true
+ )
+ val initialContent = LiveUpdateContent(
+ name = "name",
+ content = initial,
+ timestamp = 0,
+ )
+ val initialLiveUpdate = LiveUpdateStateWithContent(initialState, initialContent)
+
+ val updatedContent = initialContent.copy(
+ content = updated,
+ timestamp = 10,
+ )
+
+ coEvery { dao.get(eq("name")) } returns initialLiveUpdate
+
+ processor.handlerCallbacks.test {
+ processor.enqueue(
+ Operation.Update(
+ name = "name",
+ content = updated,
+ timestamp = 10,
+ )
+ )
+
+ awaitItem().let {
+ assertTrue(it.action == LiveUpdateEvent.UPDATE)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ assertEquals(updated, it.update.content)
+ }
+
+ coVerifySequence {
+ dao.get(eq("name"))
+ dao.upsert(state = eq(initialState), content = eq(updatedContent))
+ }
+
+ ensureAllEventsConsumed()
+ }
+ }
+
+ @Test
+ public fun testStop(): TestResult = runTest(testDispatcher) {
+ val state = LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = 0,
+ dismissalDate = null,
+ isActive = true
+ )
+ val content = LiveUpdateContent(
+ name = "name",
+ content = jsonMapOf("foo" to "bar"),
+ timestamp = 0,
+ )
+ val liveUpdate = LiveUpdateStateWithContent(state = state, content = content)
+
+ val stopTime = 10L
+ val stopState = state.copy(
+ isActive = false,
+ timestamp = stopTime
+ )
+ val stopContent = content.copy(
+ content = jsonMapOf("fizz" to "buzz"),
+ timestamp = stopTime
+ )
+
+ coEvery { dao.isAnyActive() } returns false
+ coEvery { dao.get(eq("name")) } returns liveUpdate
+
+ processor.handlerCallbacks.test {
+
+ processor.enqueue(
+ Operation.Stop(name = "name", content = stopContent.content, timestamp = stopTime)
+ )
+
+ awaitItem().let {
+ assertTrue(it.action == LiveUpdateEvent.END)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ }
+
+ coVerifySequence {
+ dao.get(eq("name"))
+ dao.upsert(eq(stopState), eq(stopContent))
+ dao.deleteContent(eq("name"))
+ dao.isAnyActive()
+ }
+
+ advanceUntilIdle()
+ ensureAllEventsConsumed()
+ }
+ }
+
+ @Test
+ public fun testStartUpdateStop(): TestResult = runTest(testDispatcher) {
+ val initialState = LiveUpdateState(
+ name = "name",
+ type = "type",
+ timestamp = 0,
+ dismissalDate = 1000,
+ isActive = false
+ )
+ val initialContent = LiveUpdateContent(
+ name = "name",
+ content = jsonMapOf("foo" to "bar"),
+ timestamp = 0,
+ )
+ val initialLiveUpdate = LiveUpdateStateWithContent(initialState, initialContent)
+
+ val startedState = initialState.copy(isActive = true)
+ val startedContent = initialContent.copy()
+ val startedLiveUpdate = LiveUpdateStateWithContent(startedState, startedContent)
+
+ val updatedState = startedState.copy()
+ val updatedContent = startedContent.copy(
+ content = jsonMapOf("fizz" to "buzz"),
+ timestamp = 10
+ )
+ val updatedLiveUpdate = LiveUpdateStateWithContent(updatedState, updatedContent)
+
+ val stoppedState = updatedState.copy(isActive = false, timestamp = 20, dismissalDate = 2000)
+ val stoppedContent = updatedContent.copy(
+ content = jsonMapOf("slim" to "none"),
+ timestamp = 20
+ )
+
+ // Mock initial state
+ coEvery { dao.getState(eq("name")) } returns initialLiveUpdate.state
+
+ processor.handlerCallbacks.test {
+ // Enqueue start operation
+ processor.enqueue(
+ Operation.Start(
+ name = "name",
+ type = "type",
+ content = initialContent.content,
+ timestamp = initialContent.timestamp,
+ dismissalTimestamp = initialState.dismissalDate
+ )
+ )
+
+ // Verify start effect
+ awaitItem().let {
+ println("gotItem = $it")
+
+ assertTrue(it.action == LiveUpdateEvent.START)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ assertEquals(initialContent.content, it.update.content)
+ assertEquals(initialState.dismissalDate, it.update.dismissalTime)
+ }
+
+ // Verify insert dao calls
+ coVerifyOrder {
+ dao.getState(eq("name"))
+ dao.upsert(any(), any())
+ }
+
+ // We shouldn't have any additional effects
+ ensureAllEventsConsumed()
+
+ // Reset mocks for update operation
+ clearMocks(dao)
+ coEvery { dao.get(eq("name")) } returns startedLiveUpdate
+ coEvery { dao.getState(eq("name")) } returns startedLiveUpdate.state
+
+ // Enqueue update
+ processor.enqueue(
+ Operation.Update(
+ name = "name",
+ content = updatedContent.content,
+ timestamp = updatedContent.timestamp,
+ )
+ )
+
+ // Verify update effect
+ awaitItem().let {
+ assertTrue(it.action == LiveUpdateEvent.UPDATE)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ assertEquals(updatedContent.content, it.update.content)
+ }
+
+ // Verify update dao calls
+ coVerifySequence {
+ dao.get(eq("name"))
+ dao.upsert(state = eq(updatedState), content = eq(updatedContent))
+ }
+
+ // We shouldn't have any additional effects
+ ensureAllEventsConsumed()
+
+ // Reset mocks for stop operation
+ clearMocks(dao)
+ coEvery { dao.isAnyActive() } returns false
+ coEvery { dao.get(eq("name")) } returns updatedLiveUpdate
+ coEvery { dao.getState(eq("name")) } returns updatedLiveUpdate.state
+
+ // Enqueue stop (with an update to the dismissal timestamp)
+ processor.enqueue(
+ Operation.Stop(
+ name = "name",
+ content = stoppedContent.content,
+ timestamp = stoppedContent.timestamp,
+ dismissalTimestamp = stoppedState.dismissalDate
+ )
+ )
+
+ // Verify stop effect
+ awaitItem().let {
+ assertTrue(it.action == LiveUpdateEvent.END)
+
+ assertEquals("name", it.update.name)
+ assertEquals("type", it.update.type)
+ assertEquals(stoppedContent.content, it.update.content)
+ assertEquals(stoppedState.dismissalDate, it.update.dismissalTime)
+ }
+
+ // Verify stop dao calls
+ coVerifySequence {
+ dao.get(eq("name"))
+ dao.upsert(state = eq(stoppedState), content = eq(stoppedContent))
+ dao.deleteContent(eq("name"))
+ dao.isAnyActive()
+ }
+
+ // Run until idle and ensure no additional effects are emitted
+ advanceUntilIdle()
+ ensureAllEventsConsumed()
+ }
+ }
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateRegistrarTest.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateRegistrarTest.kt
new file mode 100644
index 000000000..8888f9c33
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateRegistrarTest.kt
@@ -0,0 +1,127 @@
+package com.urbanairship.liveupdate
+
+import androidx.core.app.NotificationManagerCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.urbanairship.TestApplication
+import com.urbanairship.job.JobDispatcher
+import com.urbanairship.liveupdate.LiveUpdateProcessor.Operation
+import com.urbanairship.liveupdate.data.LiveUpdateDao
+import com.urbanairship.liveupdate.data.LiveUpdateDatabase
+import com.urbanairship.liveupdate.notification.NotificationTimeoutCompat
+import com.urbanairship.liveupdate.util.jsonMapOf
+import io.mockk.clearMocks
+import io.mockk.mockk
+import io.mockk.verifySequence
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+public class LiveUpdateRegistrarTest {
+
+ private val context = TestApplication.getApplication()
+ private val testDispatcher = StandardTestDispatcher()
+
+ private val database: LiveUpdateDatabase = mockk(relaxed = true)
+ private val dao: LiveUpdateDao = mockk(relaxed = true)
+ private val processor: LiveUpdateProcessor = mockk(relaxed = true)
+ private val notificationManager: NotificationManagerCompat = mockk(relaxed = true)
+ private val jobDispatcher: JobDispatcher = mockk(relaxed = true)
+ private val notificationTimeoutCompat: NotificationTimeoutCompat = mockk(relaxed = true)
+
+ private lateinit var registrar: LiveUpdateRegistrar
+
+ @Before
+ public fun setUp() {
+ registrar = LiveUpdateRegistrar(
+ context = context,
+ dao = dao,
+ processor = processor,
+ dispatcher = testDispatcher,
+ notificationManager = notificationManager,
+ jobDispatcher = jobDispatcher,
+ notificationTimeoutCompat = notificationTimeoutCompat
+ )
+ verifySequence {
+ processor.handlerCallbacks
+ processor.notificationCancels
+ processor.channelUpdates
+ }
+ // Clear the initial handlerCallbacks call
+ clearMocks(processor)
+ }
+
+ @After
+ public fun tearDown() {
+ database.close()
+ }
+
+ @Test
+ public fun testRegisterHandler(): TestResult = runTest(testDispatcher) {
+ assertTrue(registrar.handlers.isEmpty())
+
+ val handler = TestHandler()
+ registrar.register(TYPE, handler)
+
+ assertEquals(1, registrar.handlers.size)
+ assertEquals(handler, registrar.handlers[TYPE])
+ }
+
+ @Test
+ public fun testStart(): TestResult = runTest(testDispatcher) {
+ val handler = TestHandler()
+ registrar.register(TYPE, handler)
+
+ registrar.start(NAME, TYPE, CONTENT, TIMESTAMP, DISMISS_TIMESTAMP)
+
+ val expected = Operation.Start(NAME, TYPE, CONTENT, TIMESTAMP, DISMISS_TIMESTAMP)
+
+ verifySequence {
+ processor.enqueue(eq(expected))
+ }
+ }
+
+ @Test
+ public fun testUpdate(): TestResult = runTest(testDispatcher) {
+ val handler = TestHandler()
+ registrar.register(TYPE, handler)
+
+ registrar.start(NAME, TYPE, CONTENT, TIMESTAMP, DISMISS_TIMESTAMP)
+
+ val expected = Operation.Start(NAME, TYPE, CONTENT, TIMESTAMP, DISMISS_TIMESTAMP)
+
+ verifySequence {
+ processor.enqueue(eq(expected))
+ }
+ }
+
+ @Test
+ public fun testStop(): TestResult = runTest(testDispatcher) {
+ val handler = TestHandler()
+ registrar.register(TYPE, handler)
+
+ registrar.stop(NAME, CONTENT, TIMESTAMP, DISMISS_TIMESTAMP)
+
+ val expected = Operation.Stop(NAME, CONTENT, TIMESTAMP, DISMISS_TIMESTAMP)
+
+ verifySequence {
+ processor.enqueue(eq(expected))
+ }
+ }
+
+ private companion object {
+ private const val NAME = "name"
+ private const val TYPE = "type"
+ private const val TIMESTAMP = 1000L
+ private const val DISMISS_TIMESTAMP = 9000L
+ private val CONTENT = jsonMapOf("foo" to "bar")
+ }
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateStressTest.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateStressTest.kt
new file mode 100644
index 000000000..813c9e276
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/LiveUpdateStressTest.kt
@@ -0,0 +1,158 @@
+package com.urbanairship.liveupdate
+
+import androidx.core.app.NotificationManagerCompat
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.urbanairship.liveupdate.data.LiveUpdateDao
+import com.urbanairship.liveupdate.data.LiveUpdateDatabase
+import com.urbanairship.liveupdate.util.jsonMapOf
+import io.mockk.mockk
+import io.mockk.spyk
+import junit.framework.TestCase
+import kotlinx.coroutines.DelicateCoroutinesApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.newFixedThreadPoolContext
+import kotlinx.coroutines.newSingleThreadContext
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+// TODO: This is probably not all that useful outside of development...
+// Consider removing it once things feel solid, or if this is flaky in CI.
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(AndroidJUnit4::class)
+public class LiveUpdateStressTest {
+
+ private val context = InstrumentationRegistry.getInstrumentation().context
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private val mainDispatcher = newSingleThreadContext("UI")
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private val dbDispatcher = newFixedThreadPoolContext(16, "DB")
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private val ioDispatcher = newFixedThreadPoolContext(16, "I/O")
+
+ @OptIn(DelicateCoroutinesApi::class)
+ private val registrarDispatcher = newFixedThreadPoolContext(16, "C/B")
+
+ private val processorDispatcher = AirshipDispatchers.newSingleThreadDispatcher()
+
+ private lateinit var database: LiveUpdateDatabase
+ private lateinit var dao: LiveUpdateDao
+ private lateinit var processor: LiveUpdateProcessor
+ private lateinit var registrar: LiveUpdateRegistrar
+ private val notificationManager: NotificationManagerCompat = mockk(relaxed = true)
+
+ @Before
+ public fun setUp() {
+ Dispatchers.setMain(mainDispatcher)
+
+ database = LiveUpdateDatabase.createInMemoryDatabase(context, dbDispatcher)
+ dao = spyk(database.liveUpdateDao())
+ processor = spyk(LiveUpdateProcessor(dao, processorDispatcher))
+
+ registrar = LiveUpdateRegistrar(
+ context = context,
+ dao = dao,
+ dispatcher = registrarDispatcher,
+ processor = processor,
+ notificationManager = notificationManager
+ )
+ }
+
+ @After
+ public fun tearDown() {
+ database.close()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ public fun stressTestLiveUpdates(): TestResult = runBlocking {
+ val handler1 = TestHandler()
+ val handler2 = TestHandler()
+
+ registrar.register("type-1", handler1)
+ registrar.register("type-2", handler2)
+
+ val updateCount = 25
+
+ val job1 = asyncLiveUpdates(id = 1, repeat = updateCount)
+ val job2 = asyncLiveUpdates(id = 2, repeat = updateCount)
+
+ // Log processor status for debugging.
+ val job3 = async(context = ioDispatcher) {
+ while (processor.isProcessing.not()) {
+ delay(300)
+ println("waiting...")
+ }
+ while (processor.isProcessing) {
+ delay(300)
+ println("processing...")
+ }
+
+ delay(1000)
+ }
+
+ awaitAll(job1, job2, job3)
+
+ val expected = updateCount + 2 // updates + start + stop
+ TestCase.assertEquals(expected, handler1.events.size)
+ TestCase.assertEquals(expected, handler2.events.size)
+ }
+
+ private suspend fun asyncLiveUpdates(id: Int, repeat: Int, sleep: Long? = 5) = coroutineScope {
+ async {
+ val name = "test-$id"
+ val type = "type-$id"
+
+ sleep?.let { delay(it) }
+
+ start(name, type, 0)
+
+ repeat(repeat) { i ->
+ sleep?.let { delay(it) }
+ update(name, i + 1)
+ }
+
+ sleep?.let { delay(it) }
+ stop(name, repeat + 2)
+ }
+ }
+
+ private fun start(name: String, type: String, n: Int): Unit =
+ registrar.start(
+ name = name,
+ type = type,
+ content = jsonMapOf("n" to n),
+ timestamp = System.currentTimeMillis(),
+ dismissalTimestamp = null
+ )
+
+ private fun stop(name: String, n: Int): Unit =
+ registrar.stop(
+ name = name,
+ content = jsonMapOf("n" to n),
+ timestamp = System.currentTimeMillis(),
+ dismissalTimestamp = null
+ )
+
+ private fun update(name: String, n: Int): Unit =
+ registrar.update(
+ name = name,
+ content = jsonMapOf("n" to n),
+ timestamp = System.currentTimeMillis(),
+ dismissalTimestamp = null
+ )
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/TestHandler.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/TestHandler.kt
new file mode 100644
index 000000000..6af926999
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/TestHandler.kt
@@ -0,0 +1,21 @@
+package com.urbanairship.liveupdate
+
+import android.content.Context
+
+internal class TestHandler : LiveUpdateCustomHandler {
+ private val _events = mutableListOf()
+ internal val events: List
+ get() = _events.toList()
+
+ override fun onUpdate(context: Context, event: LiveUpdateEvent, update: LiveUpdate): LiveUpdateResult {
+ println("HANDLER onUpdate(action: $event, update: $update")
+ _events.add(Event(event, update))
+
+ return LiveUpdateResult.ok()
+ }
+
+ data class Event(
+ val action: LiveUpdateEvent,
+ val update: LiveUpdate
+ )
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClientTest.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClientTest.kt
new file mode 100644
index 000000000..4537967ea
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/api/ChannelBulkUpdateApiClientTest.kt
@@ -0,0 +1,125 @@
+/* Copyright Airship and Contributors */
+
+package com.urbanairship.liveupdate.api
+
+import com.urbanairship.BaseTestCase
+import com.urbanairship.TestAirshipRuntimeConfig
+import com.urbanairship.TestRequest
+import com.urbanairship.channel.AttributeMutation
+import com.urbanairship.channel.SubscriptionListMutation.newSubscribeMutation
+import com.urbanairship.channel.SubscriptionListMutation.newUnsubscribeMutation
+import com.urbanairship.channel.TagGroupsMutation.newAddTagsMutation
+import com.urbanairship.channel.TagGroupsMutation.newSetTagsMutation
+import com.urbanairship.config.AirshipUrlConfig
+import com.urbanairship.http.RequestFactory
+import com.urbanairship.json.JsonValue
+import org.intellij.lang.annotations.Language
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+public class ChannelBulkUpdateApiClientTest : BaseTestCase() {
+
+ private lateinit var client: ChannelBulkUpdateApiClient
+ private lateinit var testRequest: TestRequest
+
+ @Before
+ public fun setup() {
+ testRequest = TestRequest()
+ val config = TestAirshipRuntimeConfig.newTestConfig().apply {
+ urlConfig = AirshipUrlConfig.newBuilder()
+ .setDeviceUrl("https://example.com")
+ .build()
+ }
+ client = ChannelBulkUpdateApiClient(config, object : RequestFactory() {
+ override fun createRequest(): TestRequest = testRequest
+ })
+ }
+
+ @Test
+ public fun testBulkUpdateRequestSuccess() {
+ testRequest.responseStatus = 200
+
+ val payload = ChannelBulkUpdateRequest(channelId = CHANNEL_ID)
+
+ val response = client.update(CHANNEL_ID)
+
+ assertEquals("PUT", testRequest.requestMethod)
+ assertEquals("https://example.com/api/channels/sdk/batch/$CHANNEL_ID?platform=android", testRequest.url.toString())
+ assertEquals(payload.toJsonValue().toString(), testRequest.requestBody)
+ assertEquals(200, response.status)
+ }
+
+ @Test
+ public fun testChannelBulkUpdateRequestPayload() {
+ val payload = ChannelBulkUpdateRequest(
+ channelId = CHANNEL_ID,
+ subscriptionLists = listOf(
+ newSubscribeMutation("intriguing_ideas", 0L),
+ newUnsubscribeMutation("animal_facts", 1L)
+ ),
+ tagGroups = listOf(
+ newAddTagsMutation("group1", setOf("tag1")),
+ newAddTagsMutation("group2", setOf("tag3")),
+ newSetTagsMutation("group2", setOf("tag4")),
+ ),
+ attributes = listOf(
+ AttributeMutation.newSetAttributeMutation("position", JsonValue.wrap("LF"), 2L),
+ AttributeMutation.newRemoveAttributeMutation("minor_league", 3L),
+ )
+ )
+ val expected = JsonValue.parseString(EXPECTED_BATCH_UPDATE_JSON)
+ assertEquals(expected, payload.toJsonValue())
+ }
+
+ private companion object {
+ val CHANNEL_ID = "channelId"
+
+ @Language("JSON")
+ val EXPECTED_BATCH_UPDATE_JSON = """
+ {
+ "attributes": [
+ {
+ "action": "set",
+ "value": "LF",
+ "key": "position",
+ "timestamp": "1970-01-01T00:00:00"
+ },
+ {
+ "action": "remove",
+ "key": "minor_league",
+ "timestamp": "1970-01-01T00:00:00"
+ }
+ ],
+ "subscription_lists": [
+ {
+ "action": "subscribe",
+ "list_id": "intriguing_ideas",
+ "timestamp": "1970-01-01T00:00:00"
+ },
+ {
+ "action": "unsubscribe",
+ "list_id": "animal_facts",
+ "timestamp": "1970-01-01T00:00:00"
+ }
+ ],
+ "tags": [
+ {
+ "set": {
+ "group2": [
+ "tag4"
+ ]
+ }
+ },
+ {
+ "add": {
+ "group1": [
+ "tag1"
+ ]
+ }
+ }
+ ]
+ }
+ """.trimIndent()
+ }
+}
diff --git a/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/data/LiveUpdateDaoTest.kt b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/data/LiveUpdateDaoTest.kt
new file mode 100644
index 000000000..0bba9f4c7
--- /dev/null
+++ b/urbanairship-live-update/src/test/java/com/urbanairship/liveupdate/data/LiveUpdateDaoTest.kt
@@ -0,0 +1,123 @@
+package com.urbanairship.liveupdate.data
+
+import com.urbanairship.BaseTestCase
+import com.urbanairship.TestApplication
+import com.urbanairship.liveupdate.util.jsonMapOf
+import junit.framework.TestCase.assertEquals
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestResult
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+public class LiveUpdateDaoTest : BaseTestCase() {
+ private val context = TestApplication.getApplication()
+ private val testDispatcher = StandardTestDispatcher()
+
+ private lateinit var db: LiveUpdateDatabase
+ private lateinit var dao: LiveUpdateDao
+
+ @Before
+ public fun setUp() {
+ db = LiveUpdateDatabase.createInMemoryDatabase(context, testDispatcher)
+ dao = db.liveUpdateDao()
+ }
+
+ @After
+ public fun tearDown() {
+ db.close()
+ }
+
+ @Test
+ public fun testInsert(): TestResult = runTest(testDispatcher) {
+ assertRowCount(0)
+
+ dao.upsert(STATE_V1)
+ dao.upsert(CONTENT_V1)
+
+ assertRowCount(1)
+ assertEquals(ENTITY_V1, dao.get(NAME))
+ }
+
+ @Test
+ public fun testUpdateState(): TestResult = runTest(testDispatcher) {
+ suspend fun assert(count: Int, activeCount: Int) {
+ assertRowCount(count)
+ assertEquals(activeCount, dao.getAllActive().size)
+ }
+
+ dao.upsert(STATE_V1)
+ dao.upsert(CONTENT_V1)
+ assert(count = 1, activeCount = 0)
+
+ dao.upsert(STATE_V1.copy(isActive = true, timestamp = TIME_V2))
+ assert(count = 1, activeCount = 1)
+
+ dao.upsert(STATE_V1.copy(isActive = false, timestamp = TIME_V3))
+ assert(count = 1, activeCount = 0)
+ }
+
+ @Test
+ public fun testUpdateContent(): TestResult = runTest(testDispatcher) {
+ /** Asserts that we have a single Live Update that is not active. */
+ suspend fun assert() {
+ assertRowCount(1)
+ assertEquals(STATE_V1, dao.getState(NAME))
+ }
+
+ dao.upsert(STATE_V1)
+ dao.upsert(CONTENT_V1)
+
+ assert()
+ assertEquals(CONTENT_V1, dao.getContent(NAME))
+
+ dao.upsert(CONTENT_V2)
+
+ assert()
+ assertEquals(CONTENT_V2, dao.getContent(NAME))
+ }
+
+ private suspend fun assertRowCount(count: Int) {
+ assertEquals(count, dao.countState())
+ assertEquals(count, dao.countContent())
+ }
+
+ @Suppress("unused")
+ private companion object {
+ private const val NAME = "live-update-name"
+ private const val TYPE = "live-update-type"
+
+ private const val TIME_V1 = 10L
+ private val STATE_V1 = LiveUpdateState(
+ name = NAME,
+ type = TYPE,
+ timestamp = TIME_V1,
+ isActive = false
+ )
+ private val CONTENT_V1 = LiveUpdateContent(
+ name = NAME,
+ content = jsonMapOf("foo" to "bar"),
+ timestamp = TIME_V1
+ )
+
+ private val ENTITY_V1 = LiveUpdateStateWithContent(state = STATE_V1, content = CONTENT_V1)
+
+ private const val TIME_V2 = 20L
+ private val CONTENT_V2 = CONTENT_V1.copy(
+ content = jsonMapOf("fizz" to "buzz"),
+ timestamp = TIME_V2
+ )
+
+ private val ENTITY_V2 = ENTITY_V1.copy(content = CONTENT_V2)
+
+ private const val TIME_V3 = 30L
+ private val CONTENT_V3 = CONTENT_V2.copy(
+ content = jsonMapOf("slim" to "none"),
+ timestamp = TIME_V3
+ )
+ private val ENTITY_V3 = ENTITY_V2.copy(content = CONTENT_V3)
+ }
+}
diff --git a/urbanairship-live-update/src/test/resources/robolectric.properties b/urbanairship-live-update/src/test/resources/robolectric.properties
new file mode 100644
index 000000000..e24669d2f
--- /dev/null
+++ b/urbanairship-live-update/src/test/resources/robolectric.properties
@@ -0,0 +1,2 @@
+sdk=28
+application=com.urbanairship.TestApplication
\ No newline at end of file
diff --git a/urbanairship-preference-center/build.gradle b/urbanairship-preference-center/build.gradle
index 96b2d17e0..84a3b7ea3 100644
--- a/urbanairship-preference-center/build.gradle
+++ b/urbanairship-preference-center/build.gradle
@@ -100,7 +100,10 @@ dependencies {
androidTestImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.test.espresso.core)
- androidTestImplementation(libs.androidx.test.espresso.contrib)
+ androidTestImplementation(libs.androidx.test.espresso.contrib) {
+ // https://github.com/android/android-test/issues/861
+ exclude group: 'org.checkerframework', module: 'checker'
+ }
androidTestImplementation(libs.androidx.test.espresso.intents)
androidTestImplementation(libs.androidx.test.ext.junit)