Live Activities in iOS with FCM & APNS

Quinton Pryce
1v1Me Blog
Published in
6 min readJul 4, 2023

If you came here because you wanted to use FCM to update your live activity, I’m sorry. At the time of this article, FCM does not currently support custom payloads. However! 1v1Me uses FCM & we spent less than a day writing the code to use APS directly AND YOU CAN TOO! 🥳

We had a few challenges with this project:

  • The Apple docs are incomplete (and have the wrong keys)
  • The payload is finicky & difficult to debug decoding errors
  • Using network images is undocumented & not obvious

This blog isn’t going to provide detailed, step-by-step guidance. Rather, it will highlight some of the difficulties we encountered. So I highly recommend familiarizing yourself with the Apple docs first.

Getting Your APNS Token

When a new activity is created, a new push token is generated and we subscribe to the corresponding live activity via our subscribeToLiveActivity method.

@available(iOS 16.2, *)
private func start(attributes: EventActivityAttributes, state: EventActivityAttributes.EventState) {
do {
//…

let activity = try Activity<EventActivityAttributes>.request(
attributes: attributes,
content: content,
pushType: .token
)

Log.d("Requested an Event Activity \(activity.id)")
Task {
for await data in activity.pushTokenUpdates {
let token = data.map { String(format: "%02x", $0) }.joined()

Service.stake.subscribeToLiveActivity(eventId: attributes.eventId, token: token, completion: { _ in })
}
}

} catch {
Log.d("Error requesting Event Activity \(error.localizedDescription) \(error)")
}
}

This method collects token updates asynchronously, with each update containing raw bytes that we convert to a string representation of the hexadecimal value. The resulting string (the token) is then used to subscribe to the live activity with our backend, which will be making use of push notifications to update our live activity.

Decoding/Encoding

Let’s shift our focus to the ContentState struct. The widget uses this (and the ActivityAttributes) to receive and decode data from your app & your server. The Coding aspect of our live activity caused us a few headaches to say the least (it took longer to dubug this then anything else in the project…).

Debugging Issues

Debugging the widget can be difficult. I highly recommend writing a custom initializer for your content state and printing any of your debugging errors with the error information to the console. Apple does let you know there is an error in your console, but it won’t give you the specifics needed to debug the issue (ex. which key is the offending member in decoding errors).

init(from decoder: Decoder) throws {
do {
//...
} catch let DecodingError.dataCorrupted(context) {
print(context)
throw DecodingError.dataCorrupted(context)
} catch let DecodingError.keyNotFound(key, context) {
print("Key '\(key)' not found:", context.debugDescription)
print("codingPath:", context.codingPath)
throw DecodingError.keyNotFound(key, context)
} catch let DecodingError.valueNotFound(value, context) {
print("Value '\(value)' not found:", context.debugDescription)
print("codingPath:", context.codingPath)
throw DecodingError.valueNotFound(value, context)
} catch let DecodingError.typeMismatch(type, context) {
print("Type '\(type)' mismatch:", context.debugDescription)
print("codingPath:", context.codingPath)
throw DecodingError.typeMismatch(type, context)
} catch {
print("error: ", error)
throw error
}
}

Custom Decoding & Dates

When your app initializes the struct, it will use Apple’s default encoding strategies. So if you decode an object that is shared between the two targets with a date (for example) and want to encode that date, your widget will not be able to decode it. I tried many many ways around this (widget target only custom decoders, app only targeted encoders), but nothing worked. The only way around this was to define a new “Widget Safe Struct” (ie. not containing any custom types) and using it to send data to your widget.

When you receive any dates in your ContentState, they either need to match the default date format Apple has provided or you can use a TimeInterval/ Double and convert in inside the widget to a Date if you need it.

struct ContentState: Codable, Hashable {
//…

init(from decoder: Decoder) throws {
do {
let container = try decoder.container(keyedBy: CodingKeys.self)

let epochStartDate = try container.decode(Double.self, forKey: .startDate)
startDate = Date(timeIntervalSince1970: epochStartDate)

//…
} catch {
throw error
}
}

//…
}

We use the Date object heavily in the live activity, so we decode the Double into a date object.

Passing Large Content States (Images)

The content state is limited to 4mb, which means you can’t encode image data. We’ll talk about how to fix this in the next section.

Using URL/Network Images

We use a lot of network images at 1v1Me, and it was a requirement for us to be able to send them.

Nowhere in the docs does it mention how to use network images, but it was asked in a live coding session in a WWDC session & the answer they gave was that the best solution is to use App Groups & UserDefaults. The following is a simple implementation of that:

Our Live Activities Network Image View

struct NetworkImage: View {
let url: URL?
var renderingMode: Image.TemplateRenderingMode = .original
var placeholder: Image?

var body: some View {
Group {
if let url = url, let imageData = UserDefaults.appGroupsDefaults?.data(forKey: url.absoluteString),
let uiImage = UIImage(data: imageData)?.resized(toWidth: 300)?.withRenderingMode(renderingMode == .original ? .alwaysOriginal : .alwaysTemplate) {
Image(uiImage: uiImage)
.resizable()

} else if let placeholder {
placeholder.foregroundColor(.theme.gray3)
} else {
Spacer()
}
}
}
}

// Many of our imagery tends to be larger than the
// max size Apple has in place (1400x500px)
extension UIImage {
func resized(toWidth width: CGFloat, isOpaque: Bool = false) -> UIImage? {
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image { _ in
draw(in: CGRect(origin: .zero, size: canvas))
}
}
}

extension UserDefaults {
static var appGroupsDefaults: UserDefaults? = UserDefaults(suiteName: "group.com.widgets.images")
}

Our Upload Technique For Network Images

We use KingFisher in our main app, but you can use whatever method you use to download images (ie. URLSession).

func downloadAndStoreInAppGroup(url: URL?) async {
guard let url else { return }
await withCheckedContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil, completionHandler: { result in
// Log.t("Downloaded image from Kingfisher for Widget \(result)")
switch result {
case .success(let imageResult):
guard let data = imageResult.data() else { return }
Current.appGroupDefaults?.set(data, forKey: url.absoluteString)

case .failure:
break
}

continuation.resume()
})
}
}

Server Side Formatting

We are using CURB for our HTTP Library which you’ll need to add or use something similar. Ensure that you have added your appropriate Service Key which can be generated on the Apple Developer Portal.

Nothing special about this implementation other than the formatting that we’ve included below & the push URLs that you’ll need for sandbox (Debug iOS builds) and production (Release iOS builds).

SANDBOX_PUSH_URL = 'https://api sandbox push apple com:443/3/device/%s' freeze
PRODUCTION_PUSH_URL = 'https://api push apple com:443/3/device/%s' freeze

ACTIVITY_EVENT_TYPES = {
update: 'update',
end: 'end'
} freeze

def self live_activity(device_token, event, relevancy_score, dismissal_date, content_state = {}, alert = {})
ec_key = OpenSSL::PKey::EC new(AppleKeys push_key_private)
jwt_token = JWT encode({ iss: ENV['APPLE_TEAM_ID'], iat: Time now to_i }, ec_key, 'ES256', { kid: ENV['APNS_KEY_ID'] })

payload = {
aps: {
alert: alert,
timestamp: Time now to_i,
event: event,
'relevance-score' => relevancy_score,
'content-state' => content_state
}
}

payload[:aps]['dismissal-date'] = dismissal_date unless dismissal_date nil?

http = Curl::Easy http_post(format(ENV['PUSH_URL'], device_token), payload to_json) do |curl|
curl headers['Content-Type'] = 'application/json'
curl headers['Accept'] = 'application/json'
curl headers['apns-topic'] = "#{ENV['BUNDLE_ID']} push-type liveactivity"
curl headers['apns-priority'] = '10'
curl headers['apns-push-type'] = 'liveactivity'
curl headers['Authorization'] = "Bearer #{jwt_token}"
end

raise "Error sending live activity push notification: #{http body}" unless http response_code <= 299

true
end

Conclusion

In conclusion, implementing Live Activities can significantly enrich the user experience of your app by extending it to the Lock Screen & Dynamic Island. However, this feature comes with its unique set of challenges. Throughout this post, we have delved into these challenges, sharing our experiences and solutions we’ve discovered along the way. From addressing incomplete Apple documentation to dealing with issues related to encoding/decoding and server-side formatting.

Our hope is that this blog post will act as a valuable guide to illuminate some of these technical intricacies and make the process of adding a Live Activity to your app a much smoother one. As with any technical implementation, continual learning and adjustment are essential, so keep exploring and innovating!

--

--

Quinton Pryce
1v1Me Blog

iOS Developer | Camper | Believer in Resilience