Supercharge User Experience: Integrating Live Tracking Notifications in React Native!

Nitesh Agarwal
CARS24 Engineering Blog
7 min readJan 30, 2024

Collaborated by — Ankit Bhalla

In the revolutionary Autopilot app, we’re not merely facilitating users to book on-the-go drivers; we’re unleashing a seamless, turbocharged experience! Recognizing the essential need for real-time updates on your designated drivers’ arrival status, we’ve elevated the game by seamlessly integrating the iOS Live Activity feature. Hot off Apple’s innovation oven in iOS 16, this feature propels the user experience to the next level. Imagine this: live updates booming directly to your notifications, keeping you in the loop without ever needing to tap that app refresh button! Autopilot — where every booking is an exhilarating joyride! 🚗✨

Buckle up for a journey into the technical nuances that will elevate our app to new heights, ensuring a smooth and enriching experience📱

Why dive headfirst into Live Activity?

The main objective of integrating Live Activity is to provide users with swift access to vital information without constantly opening the app. Picture this scenario: you’ve booked a driver, and the Live Activity feature pops up a widget on your lock screen with key details such as the driver’s name, rating, estimated time of arrival (ETA), and real-time progress. This upgrade simplifies the user experience, delivering pertinent information in a single glance.

Dynamic Island feature is accessible for iPhone 14 Pro and above, presenting an extra layer of interaction for users.

Engaging this feature unfolds when you tap on the widget, unveiling the present ETA and progress.

Embedding Live Activity into our Codebase entailed a series of steps:

  1. Introducing WidgetKit Extension
  2. Integrating SwiftUI to craft the UI for LiveActivity and Dynamic Island
  3. Developing a Native Module to initiate live activity from the React Native codebase.
  4. Gaining a comprehensive understanding of how Push Notifications collaborate with the Live Activity feature.
  5. Most importantly, rigorously testing the end-to-end flow.

Let’s decode the intricacies of each of these steps!

  1. Adding WidgetKit Extension

Start by seamlessly integrating the WidgetKit extension into your app. Consult the Apple Documentation for a comprehensive guide with detailed instructions.

2. Updating Info.plist

Include the NSSupportsLiveActivities key in your info.plist, enabling the app to initiate Live Activity. Set NSSupportsLiveActivities to true.

Following the addition of the extension and the key mentioned above, defining the attributes accessed by the live activity is essential. Here are the specific attributes tailored for our use case.

struct LiveActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var driverName: String
var rating: String
var progress: Double
var duration: Int
}
}

Once this is prepared, leverage SwiftUI to design the user interface. The Widgets kit empowers us with the capability to govern various aspects:

  1. Live Activity that appears on the lock screen
  2. Dynamic Island Compact view: Visible when the device runs only one live activity.
  3. Dynamic Island Expanded view: Unveiled with a long press on the dynamic island.
  4. Dynamic Island Minimum view: This is seen when the device hosts more than one live activity in the dynamic island.
struct LiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LiveActivityAttributes.self) { context in
// Lock screen UI goes
} dynamicIsland: { context in
DynamicIsland {
// Expanded view UI goes here. Compose the expanded UI through
// various regions, like leading/trailing/center/bottom
DynamicIslandExpandedRegion(.leading) {
}
DynamicIslandExpandedRegion(.trailing) {
}
DynamicIslandExpandedRegion(.bottom) {
}
} compactLeading: {
//Compact view leading content goes here
} compactTrailing: {
//Compact view trailing content goes here
} minimal: {
// Minimal view content goes here
}
}
}

Following the UI creation, it is imperative to craft a module that will initiate, update, and conclude the live activity seamlessly with the designed user interface.

import Foundation
import React
import ActivityKit

class LiveActivity: NSObject {

@objc static let sharedActivity = LiveActivity()
@objc(startActivity:rating:duration:progress:)
func startActivity(driverName: String, rating:String, duration:Int, progress: Double) {
do {
if #available(iOS 16.1, *){
let liveActivityAttributes = LiveActivityAttributes(name: "Live Activity")
let liveActivityContentState = LiveActivityAttributes.ContentState(driverName:driverName, rating: rating, progress:progress, duration:duration)
try Activity<LiveActivityAttributes>.request(attributes: liveActivityAttributes, contentState: liveActivityContentState, pushType: .token)
}else{
print("Dynamic Island and live activies not supported")
}
} catch (_) {
print("there is some error")
}
}
@objc(updateActivity:rating:duration:progress:)
func updateActivity(driverName: String, rating:String, duration:Int, progress: Double){
do {
if #available(iOS 16.1, *) {
Task {
let liveActivityContentState = LiveActivityAttributes.ContentState(driverName: driverName, rating: rating, progress: progress, duration: duration)
for activity in Activity<LiveActivityAttributes>.activities {
await activity.update(using: liveActivityContentState)
}
}
}
} catch {
print("Some error")
}
}
@objc(endActivity)
func endActivity(){
Task{
for activity in Activity<LiveActivityAttributes>.activities {
await activity.end()
}
}
}
}

We must ensure these functions are accessible to the React Native layer through Native modules.

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(LiveActivities, NSObject)
RCT_EXTERN_METHOD(startActivity:(NSString *)driverName rating:(NSString *)rating duration:(NSInteger)duration progress:(double)progress)
RCT_EXTERN_METHOD(updateActivity:(NSString *)driverName rating:(NSString *)rating duration:(NSInteger)duration progress:(double)progress)
RCT_EXTERN_METHOD(endActivity)
@end
@implementation LiveActivities
RCT_EXTERN_METHOD(startActivity:(NSString *)driverName rating:(NSString *)rating duration:(Int)duration progress:(double)progress)
- (void)startActivity:(NSString *)driverName rating:(NSString *)rating duration:(NSInteger)duration progress:(double)progress {
LiveActivity *liveActivity = [LiveActivity sharedActivity];
[liveActivity startActivity:driverName rating:rating duration:duration progress:progress];
}
RCT_EXTERN_METHOD(updateActivity:(NSString *)driverName rating:(NSString *)rating duration:(Int)duration progress:(double)progress)
- (void)updateActivity:(NSString *)driverName rating:(NSString *)rating duration:(NSInteger)duration progress:(double)progress {
LiveActivity *liveActivity = [LiveActivity sharedActivity];
[liveActivity updateActivity:driverName rating:rating duration:duration progress:progress];
}
RCT_EXTERN_METHOD(endActivity)
- (void)endActivity {
LiveActivity *liveActivity = [LiveActivity sharedActivity];
[liveActivity endActivity];
}
@end

Boom!💥 With these conquered steps, we’re now geared up to kickstart, update, and wrap up the live activity directly from the local React Native code. But hold on, before we dive deeper, let’s treat ourselves to a demo showcasing how we roll with live Activity and Dynamic Island in our app!🚀

Now, let’s dive deep into the mechanics of how Push Notifications collaborate with the Live Activity feature:

The initial step involves capturing the Live Activity push token.

At the start of each Live Activity, we obtain a unique push token asynchronously. Below is the code snippet to retrieve this push token.

Task{
for await pushToken in activity.pushTokenUpdates {
let pushTokenString = pushToken.reduce("") {
$0 + String(format: "%02x", $1)
}
Logger().log("New push token: \(pushTokenString)")
}
}

Moreover, it’s crucial to dispatch this Push Token to our server. This process needs to be encapsulated in a Task since it operates asynchronously. In certain scenarios, the push token might change the lifecycle of LiveActivity. In such cases, it becomes imperative to update the push token on the server and invalidate the previously stored push token.

Once the server acquires the push token, it initiates an APN (Apple Push Notification) request with the anticipated payload. We gain control over updating and concluding the live Activity from the server side.

The payload for updating data in LiveActivity

curl -v \
--header "apns-topic:{App Bundle Id}.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "apns-priority: 10" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data \
'{"aps": {
"timestamp":'$(date +%s)',
"event": "update",
"content-state": {
"driverName":{Driver Name},
"rating": {rating},
"progress": {progress},
"duration": {duration}
}
}}' \
--http2 \
https://${APNS_HOST_NAME}/3/device/$PUSH_TOKEN

For updating data in LiveActivity, the header apns-push-type must be set to liveactivity. In the payload, we define the following key elements:

1. Timestamp: This key ensures Live Activity displays the most recently updated data.

2. Event: This could be either updateor end.

3. Content-state: This contains the key-value pairs defined in the ContentState Attributes expected by Live Activity.

Here’s the fascinating part: once the server triggers the update request through curl, Apple, under the hood, comprehends the incoming notification based on the push token. It then updates the content of the desired live activity based on the incoming content state.

With this, we’re all set to update the tracking states on Live Activity, delivering a seamless tracking experience.

Once the update part is completed, we conclude this live activity at the desired time. To achieve that, we need to initiate an APN request with the event type end.

Payload for Ending the live activity

curl -v \
--header "apns-topic:com.cars24.sellerengine.push-type.liveactivity" \
--header "apns-push-type:liveactivity" \
--header "apns-priority: 10" \
--header "authorization: bearer $AUTHENTICATION_TOKEN" \
--data \
'{"aps": {
"timestamp":'$(date +%s)',
"event": "end",
"content-state": {
"driverName":{Driver Name},
"rating": {rating},
"progress": {progress},
"duration": {duration}
}
}}' \
--http2 \
https://${APNS_HOST_NAME}/3/device/$DEVICE_TOKEN

After the server triggers this curl, the live activity concludes.

But here’s a crucial point: after ending the live activity, Apple retains this activity on the lock screen for 4 hours, which isn’t optimal for user experience. To address this and remove the Live Activity from the lock screen at the desired time after the cycle ends, we must include an additional payload, namely dismissal-date. When added to the payload, this ensures the removal of Live Activity from the lock screen 30 seconds after the activity concludes.

{
"aps": {
"timestamp":'$(date +%s)',
"dismissal-date":'$(($(date +%s) + 30))',
"event": "end",
"content-state": {
"driverName":{Driver Name},
"rating": {rating},
"progress": {progress},
"duration": {duration}
}
}
}

Bravo! With the infusion of Live Activity into our React Native app, we’ve turbocharged the user experience for Autopilot. Now, customers can effortlessly track the arrival of their booked drivers, waving goodbye to the days of constant app navigation. As technology accelerates, delivering such seamless and convenient features is paramount for a top-notch user experience.

Hold tight because this article is just the beginning — a thrilling introduction packed with insights! Stay tuned for the next edition, where we’ll dive into:

1. Unleashing the power of testing Live Activity Push Notifications on your local machine.
2. Unraveling the secrets behind achieving a similar live tracking marvel on Android.

Thanks for being on this tech journey with us. Share your thoughts, feedback, or any challenges you’ve conquered during development. We’re all ears! 🚀🌟

If you enjoyed reading the blog and are interested in contributing to exciting projects like these, job opportunities are available! Join us and be a part of innovative implementations. We are HIRING!

--

--