fix: remove Dynamic Island activity when app is dismissed

This commit is contained in:
ajaysehwal
2026-06-04 10:52:43 -04:00
committed by Evan Kaloudis
parent ef680ebbab
commit b142cbe98c
3 changed files with 50 additions and 48 deletions
+4 -5
View File
@@ -98,20 +98,19 @@ static void ClearKeychainIfNecessary() {
}
- (void)applicationWillTerminate:(UIApplication *)application {
NSLog(@"App will terminate stopping Live Activity");
NSLog(@"App will terminate ending Live Activities (blocking)");
if (@available(iOS 16.1, *)) {
Class managerClass = NSClassFromString(@"NWCActivityManager");
if (managerClass && [managerClass respondsToSelector:@selector(shared)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
id manager = [managerClass performSelector:@selector(shared)];
#pragma clang diagnostic pop
if ([manager respondsToSelector:@selector(stopActivity)]) {
[manager performSelector:@selector(stopActivity)];
if ([manager respondsToSelector:@selector(endAllActivitiesImmediately)]) {
[manager performSelector:@selector(endAllActivitiesImmediately)];
}
#pragma clang diagnostic pop
}
}
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:2.0]];
}
@end
+40 -40
View File
@@ -7,20 +7,18 @@ import ActivityKit
@objc static let shared = NWCActivityManager()
// Callbacks wired by NWCAudioKeepAlive.m so widget button taps reach the audio engine.
@objc var onNextTrack: (() -> Void)?
@objc var onPrevTrack: (() -> Void)?
@objc var onToggleMute: (() -> Void)?
@objc var onStop: (() -> Void)?
// Only adopt an existing activity that is still live (not ended/dismissed).
// Orphaned activities from previous crashes are ended in init().
private var activity: Activity<NWCLiveActivityAttributes>?
override private init() {
super.init()
NWCBridge = NWCWidgetBridgeImpl(manager: self)
cleanupOrphanedActivities()
// Synchronous cleanup so a force-quit island is gone on next cold start.
endAllActivitiesBlocking()
}
// ObjC entry points
@@ -37,27 +35,32 @@ import ActivityKit
}
}
/// Normal stop (async). Ends every NWC activity, not only the cached reference.
@objc func stopActivity() {
DispatchQueue.main.async { [weak self] in
self?.stopActivityOnMain()
}
}
/// Ends all NWC Live Activities and blocks until ActivityKit completes.
/// Required on force-quit: async `stopActivity` often never runs before iOS kills the process.
@objc func endAllActivitiesImmediately() {
endAllActivitiesBlocking()
}
// Main-queue implementation
// 5-minute rolling staleDate safety net if the app is killed mid-session.
private var staleDate: Date { Date().addingTimeInterval(300) }
private func startActivityOnMain(trackName: String, isMuted: Bool) {
let auth = ActivityAuthorizationInfo()
guard auth.areActivitiesEnabled else {
NSLog("[NWCActivity] Live Activities disabled go to Settings → ZEUS → Live Activities")
NSLog("[NWCActivity] Live Activities disabled Settings → ZEUS → Live Activities")
return
}
// Re-use any existing live activity (active OR stale both accept updates).
if let existing = activity, isLive(existing) {
NSLog("[NWCActivity] activity already active, updating instead")
if let existing = activity, Self.isLive(existing) {
NSLog("[NWCActivity] activity already live, updating instead")
updateActivityOnMain(trackName: trackName, isMuted: isMuted)
return
}
@@ -83,7 +86,7 @@ import ActivityKit
}
private func updateActivityOnMain(trackName: String?, isMuted: Bool) {
guard let current = activity, isLive(current) else {
guard let current = activity, Self.isLive(current) else {
NSLog("[NWCActivity] update skipped no live activity")
return
}
@@ -93,7 +96,6 @@ import ActivityKit
)
Task {
if #available(iOS 16.2, *) {
// Rolling the staleDate forward keeps the activity alive during a long session.
let content = ActivityContent(state: state, staleDate: staleDate)
await current.update(content)
} else {
@@ -105,53 +107,51 @@ import ActivityKit
}
private func stopActivityOnMain() {
guard let current = activity else { return }
activity = nil
Task {
await Self.endEveryNWCLiveActivity()
NSLog("[NWCActivity] ended (async)")
}
}
// Blocking end (force-quit + cold-start cleanup)
private func endAllActivitiesBlocking() {
let sem = DispatchSemaphore(value: 0)
Task.detached(priority: .userInitiated) {
await Self.endEveryNWCLiveActivity()
await MainActor.run { [weak self] in
self?.activity = nil
}
sem.signal()
}
_ = sem.wait(timeout: .now() + 5)
}
private static func endEveryNWCLiveActivity() async {
let finalState = NWCLiveActivityAttributes.ContentState(
currentTrackName: nil,
isMuted: false
)
Task {
for act in Activity<NWCLiveActivityAttributes>.activities {
guard isLive(act) else { continue }
if #available(iOS 16.2, *) {
let content = ActivityContent(state: finalState, staleDate: Date())
await current.end(content, dismissalPolicy: .immediate)
await act.end(content, dismissalPolicy: .immediate)
} else {
await current.end(using: finalState, dismissalPolicy: .immediate)
await act.end(using: finalState, dismissalPolicy: .immediate)
}
NSLog("[NWCActivity] ended")
NSLog("[NWCActivity] ended activity id=%@", act.id)
}
}
// Helpers
/// True when the activity can still be updated (active or stale but not ended/dismissed).
private func isLive(_ act: Activity<NWCLiveActivityAttributes>) -> Bool {
private static func isLive(_ act: Activity<NWCLiveActivityAttributes>) -> Bool {
if act.activityState == .ended { return false }
if #available(iOS 16.2, *) {
if act.activityState == .dismissed { return false }
}
return true
}
/// End any activities left over from a previous session (e.g. a crash that
/// prevented the normal stopActivity() call from completing).
private func cleanupOrphanedActivities() {
let orphans = Activity<NWCLiveActivityAttributes>.activities
guard !orphans.isEmpty else { return }
NSLog("[NWCActivity] cleaning up %d orphaned activit(y|ies)", orphans.count)
Task {
for orphan in orphans {
if #available(iOS 16.2, *) {
let content = ActivityContent(
state: NWCLiveActivityAttributes.ContentState(
currentTrackName: nil, isMuted: false),
staleDate: Date()
)
await orphan.end(content, dismissalPolicy: .immediate)
} else {
await orphan.end(using: nil, dismissalPolicy: .immediate)
}
}
}
}
}
+6 -3
View File
@@ -360,7 +360,10 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve
[nc removeObserver:self name:UIApplicationWillResignActiveNotification object:nil];
[nc removeObserver:self name:UIApplicationWillTerminateNotification object:nil];
[self unregisterDarwinObservers];
NSLog(@"[NWCAudio] Disarmed Darwin IPC removed");
if (@available(iOS 16.1, *)) {
[[NWCActivityManager shared] endAllActivitiesImmediately];
}
NSLog(@"[NWCAudio] Disarmed Darwin IPC removed, Live Activity ended");
});
resolve(@(YES));
}
@@ -685,9 +688,9 @@ RCT_EXPORT_METHOD(disarmNWCAudio:(RCTPromiseResolveBlock)resolve
* Ends the Live Activity immediately so the Dynamic Island disappears.
*/
- (void)handleWillTerminate:(NSNotification *)notification {
NSLog(@"[NWCAudio] App terminating stopping Live Activity");
NSLog(@"[NWCAudio] App terminating ending Live Activity (blocking)");
if (@available(iOS 16.1, *)) {
[[NWCActivityManager shared] stopActivity];
[[NWCActivityManager shared] endAllActivitiesImmediately];
}
}