From a4119506bfbecc892ef4836c8231718f3431e544 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 1 Oct 2022 14:07:08 -0400 Subject: [PATCH] Add Live Activities --- site/posts/2022-09-25-live-activites.md | 84 ++++++++++++++++++ site/static/2022/live-activities/console.png | Bin 0 -> 11495 bytes .../2022/live-activities/island-silent.mp4 | 3 + .../2022/live-activities/notif-center.mp4 | 3 + 4 files changed, 90 insertions(+) create mode 100644 site/posts/2022-09-25-live-activites.md create mode 100644 site/static/2022/live-activities/console.png create mode 100644 site/static/2022/live-activities/island-silent.mp4 create mode 100644 site/static/2022/live-activities/notif-center.mp4 diff --git a/site/posts/2022-09-25-live-activites.md b/site/posts/2022-09-25-live-activites.md new file mode 100644 index 0000000..f853459 --- /dev/null +++ b/site/posts/2022-09-25-live-activites.md @@ -0,0 +1,84 @@ +``` +metadata.title = "Live Activities (and Bad Apple)" +metadata.tags = ["swift"] +metadata.date = "2022-10-01 14:05:42 -0400" +metadata.shortDesc = "Getting nerd sniped for fun and—well, just fun really." +metadata.slug = "live-activities" +``` + +I recently got [nerd sniped](https://xkcd.com/356/) by [this tweet](https://twitter.com/zhuowei/status/1573711389285388288) from Zhuowei Zhang about playing the Bad Apple video in the Dynamic Island on the iPhone 14 Pro. His original implementation used a webpage and the media session API, and this worked, but the system plays an animation when the artwork changes, so the framerate was limited to 2 FPS. Not ideal for watching a video. So, I wanted to see how much closer to watchable I could get. + +This post isn't going to be a detailed guide or anything, just a collection of some mildly interesting things I learned. + + + +Before I started this, I was already aware that Live Activity updates had a 4KB limit on the size of the dynamic state. So, the first thing I worked on was encoding the video into a smaller format. Because this particular video is black and white, I encoded it as 1 bit-per-pixel. Each frame is also complete, so the widget doesn't need access to any previous frames to display the current one. + +Since I wanted to display it in the Dynamic Island, the video is also scaled down to be very small, 60 × 45 pixels—one eighth the resolution of the original. This is a convenient size because each row of pixels can be encoded as a single UInt64. The entire frame comes out to 45 × 8 = 360 bytes, which is plenty small.[^1] + +[^1]: Zhuowei [pointed out](https://notnow.dev/objects/bddcdb31-7ece-4ae1-86b1-7c5a2d23bec6) that by using a palette, you could get even get a color frame the same size into 2.8 kilobytes without even getting into any fancy encoding techniques. + +The whole video is encoded when the app starts up, which takes about 8 seconds. That's faster than real time (the video is 3m39s), so it could be done during playback, but doing it ahead of time is fast enough that I didn't feel like putting in the work to optimize a shitpost. + +The widget can then unpack the encoded frame into a bitmap that can be turned into an image. + +Adding the Live Activity wasn't difficult—the API is wonderfully straightforward—but, alas, updating it was not so. + +While the app was in the foreground, ActivityKit would log a message whenever I asked it to update the activity. But, when the app went into the background, those messages were no longer logged—even though my code was still running and requesting updates. Interestingly, the app is considered to be in the foreground if you open Notification Center while in the app, and so the activity can be updated, which is how this demo came about: + +
+ <%- video(metadata, "notif-center", {style: "max-width: 50%; margin: 0 auto; display: block;", title: "The Bad Apple video, with sound, playing back first in an app and then in a Live Activity in the Notification Center."}) %> +
+ +I scratched my head at the issue of background updates for a while, and tried a couple things to no avail, until I attached Console.app to my phone and filtered for "activity". At which point, I saw a bunch of messages like these from `sessionkitd` (which is the system daemon that manages live activities): + +
+ com.apple.activitykit sessionkitd xpc Process is playing background media and forbidden to update activity: 984 +
+ +Apps playing background audio seem to be completely forbidden from updating Live Activities. The only possible reason for this I can imagine is to prevent apps from making their own now playing activities, rather than relying on the system one. I don't know why this is the case, but whatever, back to trying to find workarounds. + +At this point, I downloaded the most recent iOS 16 IPSW for the iPhone 14 Pro. After extracting the dyld shared cache from the image (using [this](https://github.com/blacktop/ipsw) helpful tool) I started poking around to try and find the code responsible for deciding if an app is allowed to update its activities. Opening the dyld shared cache in Hopper and searching for "session" revealed several promising-sounding frameworks: SessionCore, SessionFoundation, and SessionKit. + +SessionCore turned out to be the one containing that log message. But unfortunately, it's written in Swift and I am not good enough at reverse engineering to decipher what it's actually doing. I did, however, manage to find a couple tidbits just be looking through the strings in the binary: + +1. An entitlement named `com.apple.private.sessionkit.backgroundAudioUpdater` + +It wasn't of much use to me, but if someone ever manages to jailbreak these devices, you could have some fun with it. + +2. A log message reading "Process looks like a navigation app and can update activity" + +This looked more promising because the phrasing "looks like" suggests to me that it's just using a heuristic, rather than determining what the app is for certain. I tried to trick it by adding entitlements for MapKit and location, tracking the user's location while in the background, and trying various audio session categories that seemed more map-app like. But this effort was to no avail, `sessionkitd` still forbade me from updating the activity in the background. + +At this point, I gave up on getting audio working and just settled for playing the video in the Dynamic Island. I had to scrap the previous implementation of detecting new frames because it used `AVPlayer` and is therefore incompatible with updating in the background. But, since I have an array of frames, playing it back myself is as simple as using a timer to emit a new one every 1/30th of a second. + +With that, I was finally able to play back the video in the island: + +
+ <%- video(metadata, "island-silent", {style: "max-width: 50%; margin: 0 auto; display: block;", title: "The Bad Apple video playing back silently in the left section of the Dynamic Island."}) %> +
+ +You may notice that in both attempts the video appears somewhat blurry. This is becuase iOS animates any changes to the Live Activity view. As with all widgets, the SwiftUI view tree is serialized and then deserialized and displayed in a separate process, so you don't have direct control over the animation. There is a new [`ContentTransition`](https://developer.apple.com/documentation/swiftui/contenttransition) API that the Live Activity docs say should work, but unfortunately the identity transition "which indicates that content changes should't animate" has no effect on the activity. + +Just having the video in the island was pretty satisfying to see. But, I still really wanted to see the video playing in the island with the audio. + +Zhuowei suggested using the system Music app to play back the sound while my app controlled the video. This worked, though it means the app is no longer entirely self-contained. You can use the `MPMusicPlayerController.systemMusicPlayer` to control the system Music app, and playing back a particular track is simple enough: + +```swift +let player = MPMusicPlayerController.systemMusicPlayer +let query = MPMediaQuery.songs() +query.addFilterPredicate(MPMediaPropertyPredicate(value: "badapple", forProperty: MPMediaItemPropertyTitle)) +player.setQueue(with: query) +do { + try await player.prepareToPlay() + player.play() +} catch { + // if the song doesn't exist, ignore it +} +``` + +Annoyingly, the only way of getting the track into the Music app on my phone was by disabling iCloud Music Library and syncing it from my Mac. Why iCloud needs to be disabled to let you manually sync files, I do not know—especially seeing as the manually-synced tracks remain on the device even after iCloud is turned back on. + +And this, it turns out, is impossible to record because apparently the Music app mutes itself during screen recordings (using the builtin Control Center method, or attaching to QuickTime on a Mac). So, you'll just have to take my word for it. + +This was a fun, if utterly pointless, endeavor. If you want to see the code or run it yourself, it's available [here](https://git.shadowfacts.net/shadowfacts/LiveApple). diff --git a/site/static/2022/live-activities/console.png b/site/static/2022/live-activities/console.png new file mode 100644 index 0000000000000000000000000000000000000000..703a5a25eac14da2744d310ef0e7cdc46394bd2b GIT binary patch literal 11495 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