Team-scoped keys introduce the ability to restrict your token authentication keys to either development or production environments. Topic-specific keys in addition to environment isolation allow you to associate each key with a specific Bundle ID streamlining key management.
For detailed instructions on accessing these features, read our updated documentation on establishing a token-based connection to APNs.
Delve into the world of built-in app and system services available to developers. Discuss leveraging these services to enhance your app's functionality and user experience.
Selecting any option will automatically load the page
Post
Replies
Boosts
Views
Created
Environment:
macOS 26.2 (Tahoe)
Xcode 16.3
Apple Silicon (M4)
Sandboxed Mac App Store app
Description:
Repeated use of VNRecognizeTextRequest causes permanent memory growth in the host process. The physical footprint increases by approximately 3-15 MB per OCR call and never returns to baseline, even after all references to the request, handler, observations, and image are released.
`
private func selectAndProcessImage() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.image]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.message = "Select an image for OCR processing"
guard panel.runModal() == .OK, let url = panel.url else { return }
selectedImageURL = url
isProcessing = true
recognizedText = "Processing..."
// Run OCR on a background thread to keep UI responsive
let workItem = DispatchWorkItem {
let result = performOCR(on: url)
DispatchQueue.main.async {
recognizedText = result
isProcessing = false
}
}
DispatchQueue.global(qos: .userInitiated).async(execute: workItem)
}
private func performOCR(on url: URL) -> String {
// Wrap EVERYTHING in autoreleasepool so all ObjC objects are drained immediately
let resultText: String = autoreleasepool {
// Load image and convert to CVPixelBuffer for explicit memory control
guard let imageData = try? Data(contentsOf: url) else {
return "Error: Could not read image file."
}
guard let nsImage = NSImage(data: imageData) else {
return "Error: Could not create image from file data."
}
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return "Error: Could not create CGImage."
}
let width = cgImage.width
let height = cgImage.height
// Create a CVPixelBuffer from the CGImage
var pixelBuffer: CVPixelBuffer?
let attrs: [String: Any] = [
kCVPixelBufferCGImageCompatibilityKey as String: true,
kCVPixelBufferCGBitmapContextCompatibilityKey as String: true
]
let status = CVPixelBufferCreate(
kCFAllocatorDefault,
width,
height,
kCVPixelFormatType_32ARGB,
attrs as CFDictionary,
&pixelBuffer
)
guard status == kCVReturnSuccess, let buffer = pixelBuffer else {
return "Error: Could not create CVPixelBuffer (status: \(status))."
}
// Draw the CGImage into the pixel buffer
CVPixelBufferLockBaseAddress(buffer, [])
guard let context = CGContext(
data: CVPixelBufferGetBaseAddress(buffer),
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
) else {
CVPixelBufferUnlockBaseAddress(buffer, [])
return "Error: Could not create CGContext for pixel buffer."
}
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
CVPixelBufferUnlockBaseAddress(buffer, [])
// Run OCR
let requestHandler = VNImageRequestHandler(cvPixelBuffer: buffer, options: [:])
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
do {
try requestHandler.perform([request])
} catch {
return "Error during OCR: \(error.localizedDescription)"
}
guard let observations = request.results, !observations.isEmpty else {
return "No text found in image."
}
let lines = observations.compactMap { observation in
observation.topCandidates(1).first?.string
}
// Explicitly nil out the pixel buffer before the pool drains
pixelBuffer = nil
return lines.joined(separator: "\n")
}
// Everything — Data, NSImage, CGImage, CVPixelBuffer, VN objects — released here
return resultText
}
`
macOS 26 "Tahoe" is allocating much more memory for apps than former macOS versions: A customer contacted me with my app's Thumbnail extension allocating so much memory that her 48 GB RAM Mac Mini ran into "out of application memory" state. I couldn't identify any memory leak in my extension's code nor reproduce the issue, but found the main app allocating as much as 5 times the memory compared to running on macOS 15 or lower.
This productive app is explicitly using "Liquid Glass" views as well as implicitly e.g. for an inspector pane. So I created a sample app, just based on Xcode's template of a document-based app, and the issue is still showing (although less dramatically): This sample app allocates 22 MB according to Tahoe's Activity Monitor, while Sequoia only requires 16 MB:
macOS 15.6.1
macOS 26.2
Is anyone experiencing similar issues? I suspect some massive leak in Tahoe's memory management, and just filed a corresponding feedback (FB21967167).
Topic:
App & System Services
SubTopic:
Processes & Concurrency
Tags:
Foundation
QuickLook Thumbnailing
AppKit
I'm experiencing an issue with the Analytics Reports API. Since last week, no data is being returned from the following endpoint:
https://api.appstoreconnect.apple.com/v1/analyticsReports/r2-ac29debd-e528-406d-bdfa-fab6d4403ee2/instances
The endpoint responds successfully but returns empty data for the date range where data should exist. Other report endpoints for the same app work correctly.
Please investigate why this report stopped delivering data.
Dear Apple Support Team,
Thank you for your continued support.
I would like to inquire about the behavior of CallKit.
Our company provides an office PBX extension phone application (iPhone app).
When the iPhone is placed into sleep mode (screen off) and our app receives an incoming call, the following sequence sometimes results in an audio playback panel
appearing at the bottom of the lock screen for a few seconds after the call ends(See attachment file for detail).
Sequence to reproduce the issue:
Put the iPhone into sleep mode (screen off).
Receive an incoming call to our extension phone app.
CallKit incoming call screen appears.
Answer the call.
Conduct the call.
End the call from the peer.
iOS versions with confirmed behavior:
iOS 26.0: Not observed.
iOS 26.2: Observed.
iOS 26.3: Not observed.
This behavior does not affect the call functionality itself; however, some users report that the temporary appearance of the audio playback panel feels unusual.
If there is any known reason for this behavior or any recommended workaround, we would greatly appreciate your guidance.
Additionally, if this is a known issue that was addressed in iOS 26.3, we would appreciate any information you can provide regarding that as well.
Thank you very much for your assistance.
In an ObjC framework I'm developing (a dylib) that is loaded into JRE to be used via JNI (Zulu, Graal, or "native image" from Graal+ a JAR) I implemented a naive method that collects current memory footprint of the host process: It collects 5 numbers into a simple NSDictionary with NSString keys (physical footprint, default zone bytes used and allocated, and sums for used and allocated bytes for all zones.
The code ran for some time, but at certain point my process started crashing horribly in this method -- at the last line, accessing the dictionary.
Here's the code:
-(NSDictionary *)memoryState {
NSMutableDictionary *memoryState = [NSMutableDictionary dictionaryWithCapacity:8];
// obtain process current physical memory footprint, in bytes.
task_vm_info_data_t info;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kr = task_info(mach_task_self(),
TASK_VM_INFO, (task_info_t)&info, &count);
[memoryState setObject:(kr == KERN_SUCCESS) ? @(info.phys_footprint) : [NSNull null] forKey:@"physical"];
// obtain process default zone's allocated memory, in bytes.
malloc_zone_t *zone = malloc_default_zone();
if (zone!=nil) {
malloc_statistics_t st;
malloc_zone_statistics(zone, &st);
[memoryState setObject:@(st.size_in_use) forKey:@"bytesInUseDefaultZone"];
[memoryState setObject:@(st.size_allocated) forKey:@"bytesAllocatedDefaultZone"];
}
uint64_t zone_count = 0, size_in_use =0, size_allocated = 0;
vm_address_t *zones = NULL;
unsigned int zones_count = 0;
kr = malloc_get_all_zones(mach_task_self(), NULL, &zones, &zones_count);
if (kr == KERN_SUCCESS && zones != NULL && zones_count > 0) {
for (unsigned int i = 0; i < zones_count; i++) {
malloc_zone_t *zone = (malloc_zone_t *)zones[i];
if (!zone) continue;
malloc_statistics_t st;
malloc_zone_statistics(zone, &st);
zone_count++;
size_in_use += (uint64_t)st.size_in_use;
size_allocated += (uint64_t)st.size_allocated;
}
[memoryState setObject:@(size_in_use) forKey:@"bytesInUseAllZones"];
[memoryState setObject:@(size_allocated) forKey:@"bytesAllocatedAllZones"];
}
if (zones != NULL) {
vm_deallocate(mach_task_self(), (vm_address_t)zones, zones_count * sizeof(vm_address_t));
}
return [memoryState copy];
}
my (JRE) process started crashing badly, at the last [memoryState copy]; with crash report I could not understand (looks like an infinite recursion or loop).
Any debug log messages (os_log) for this memoryState, its items or its copy would crash the same.
Finally I found that commenting out the vm_deallocate() call removes the crash.
Sorry to say - I could NOT find anywhere in the documentation anything about malloc_get_all_zones() returned data, and whether I need to deallocate it after use. Some darn AI analyzer pointed out I "had a leak" and that "Apple documentation" which it didn't provide, requires that I thus release this data.
1 ) Do I really have to deallocate the returned "zones" ?? even if I do, something here is strange - zones is a malloc_zone_t ** -- how can it be casted to (vm_address_t)zones
Where can I read actual documentation about these low level APIs and the correct use?
Thanks!
Topic:
App & System Services
SubTopic:
Core OS
How to eliminate this spacing?
I am getting bug reports from users that occasionally the AlarmKit alarms scheduled by my app are going off exactly at midnight.
In my app, users can set recurring alarms for sunrise/sunset etc. I implement this as fixed schedule alarms over the next 2-3 days with correct dates pre-computed at schedule time. I have a background task which is scheduled to run at noon every day to update the alarms for the next 2-3 days.
Are there any limitations to the fixed schedule which might be causing this unintended behavior of going off at midnight?
Hi, Submitted Family Controls entitlement request a month ago for my main focus app, got approved within a day. Submitted 3 more requests for my extensions, and it has been 16 days without any word.
Saw advice to file a code-level support with DTS in this similar forum:
https://developer.apple.com/forums/thread/812934
Is there anything else I can do before filing a code-level support? Any extra info to provide? If not, can a DTS engineer please refer me for the code-level support?
Thanks!
Hi :) I'm new to app store connect, and I just want to verify what does it take to be able to test subscription for a new app that isn't approved yet using sandbox? Or is this not possible that the app has to be approved first?
More context below:
My app is a new app, I only submitted for review and I linked the subscription from the app’s In-App Purchases and Subscriptions section on the version page when submit it for review. It got rejected for now.
When the app review status is both in-review and rejected, I've tried to test my subscription, where there is a button (like "subscribe"/"become a member") in my app that user can click on, which it calls ios's IAPProvider.startMembershipPurchase, I just get Error: [IAPService] Product not found: [<my_subscription_id>].
I ensured my subscription's product id in app store connect matches with the one in my code.
I can see the "rejected" status both on my app and the subscription.
So can anyone help clarify if the app has to be approved first in order to test subscription? Or am I missing any other setup? Or it might just be my code?
Thanks in advance! Any info is super helpful!
I have this code in my Virutalization application
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil")
process.arguments = ["image", "create", "blank",
"--fs", "none", "--format",
"ASIF", "--size", "2GiB",
url.path
]
try process.run()
process.waitUntilExit()
if process.terminationStatus == 0 {
print("✅ Disk image creation succeeded.")
} else {
print("❌ Disk image creation failed with exit code \(process.terminationStatus)")
}
} catch {
print("Process failed to launch: \(error.localizedDescription)")
return
}
this code was working fine until Tahoe 26.2. with the update of 26.3 the system freezes at process.waitUntilExit()
The code never exits and i get beech balls. This is working fine with intel macs. i am getting the problem in apple silicon m4 mac mini.
Any help would be appreciated.
If I have two consecutive calls like to perform(schedule: .immediate) like so:
func doSomething() async {
await self.perform(schedule: .immediate) {
// add log event 1 to data store
}
await self.perform(schedule: .immediate) {
// add log event 2 to data store
}
}
Can I be guaranteed that the block for log event 1 will happen after log event 2?
"log event" here is just an example, so please ignore things like storing date, etc.
Looking at the documentation here:
https://developer.apple.com/documentation/coredata/nsmanagedobjectcontext/perform(schedule:_:)
It's a little unclear whether any such guarantee is in place. However, given that the function returns the value from the block, it seems like I should be able to expect event 1 will always be executed before event 2 regardless of the schedule parameter?
Hello,
My app was rejected on iPad (iPad Air 11-inch M3, iPadOS 26.2.1) with two related issues:
Guideline 2.1 – Performance – App Completeness
“The app exhibited one or more bugs that would negatively impact users.
Bug description: the premium subscription cannot be loaded properly.”
Guideline 3.1.2 – Business – Payments – Subscriptions
“The submission did not include all the required information for apps offering auto-renewable subscriptions.”
I am using StoreKit 2 with SubscriptionStoreView to present the auto-renewable subscription.
During development:
Subscriptions load correctly in the simulator (sandbox).
On real devices, I test without a local StoreKit configuration file to fetch products from App Store Connect.
The subscription UI (title, duration, price) displays correctly when products are returned.
At the time of review, the Paid Apps Agreement was not active.
I suspect this may have caused the subscription products to fail loading on the review device.
Since then:
Paid Apps Agreement is now Active. SubscriptionStoreView should automatically show required metadata.
Because the subscription failed to load on iPad during review, the required information (title, price, duration) was not visible, which likely triggered the 3.1.2 rejection.
Additionally, in TestFlight I sometimes see inconsistent behavior where the app appears but cannot be installed (“App Not Available”).
Also, my app was rejected, but the subscription is still waiting for review.
I would really appreciate guidance on the following:
Am I potentially missing any required configuration that could prevent products from loading in production?
Is there any propagation delay after activating the Paid Apps Agreement that could affect product availability?
If I am overlooking something in configuration or testing, please let me know what I should specifically verify before resubmitting.
Thank you very much for your help.
Topic:
App & System Services
SubTopic:
StoreKit
Tags:
Subscriptions
StoreKit
In-App Purchase
TestFlight
Device: iPhone (real device)
iOS: 17.x
Permission: Granted
Notifications are scheduled using UNCalendarNotificationTrigger.
The function runs and prints "SCHEDULING STARTED".
However, notifications never appear at 8:00 AM, even the next day.
Here is my DailyNotifications file code:
import Foundation
import UserNotifications
enum DailyNotifications {
// CHANGE THESE TWO FOR TESTING / PRODUCTION
// For testing set to a few minutes ahead
static let hour: Int = 8
static let minute: Int = 0
// For production use:
// static let hour: Int = 9
// static let minute: Int = 0
static let daysToSchedule: Int = 30
private static let idPrefix = "daily-thought-"
private static let categoryId = "DAILY_THOUGHT"
// MARK: - Permission
static func requestPermission(completion: @escaping (Bool) -> Void) {
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
DispatchQueue.main.async {
completion(granted)
}
}
}
// MARK: - Schedule
static func scheduleNext30Days(isPro: Bool) {
print("SCHEDULING STARTED")
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized else {
requestPermission { granted in
if granted {
scheduleNext30Days(isPro: isPro)
}
}
return
}
// Remove old scheduled notifications
center.getPendingNotificationRequests { pending in
let idsToRemove = pending
.map { $0.identifier }
.filter { $0.hasPrefix(idPrefix) }
center.removePendingNotificationRequests(withIdentifiers: idsToRemove)
let calendar = Calendar.current
let now = Date()
for offset in 0..<daysToSchedule {
guard let date = calendar.date(byAdding: .day, value: offset, to: now) else { continue }
var comps = calendar.dateComponents([.year, .month, .day], from: date)
comps.hour = hour
comps.minute = minute
guard let scheduleDate = calendar.date(from: comps) else { continue }
if scheduleDate <= now { continue }
let content = UNMutableNotificationContent()
content.title = "Just One Thought"
content.sound = .default
content.categoryIdentifier = categoryId
if isPro {
content.body = thoughtForDate(scheduleDate)
} else {
content.body = "Your new thought is ready. Go Pro to reveal it."
}
let triggerComps = calendar.dateComponents(
[.year, .month, .day, .hour, .minute],
from: scheduleDate
)
let trigger = UNCalendarNotificationTrigger(
dateMatching: triggerComps,
repeats: false
)
let identifier = idPrefix + isoDay(scheduleDate)
let request = UNNotificationRequest(
identifier: identifier,
content: content,
trigger: trigger
)
center.add(request)
}
}
}
}
// MARK: - Cancel
static func cancelAllScheduledDailyThoughts() {
let center = UNUserNotificationCenter.current()
center.getPendingNotificationRequests { pending in
let idsToRemove = pending
.map { $0.identifier }
.filter { $0.hasPrefix(idPrefix) }
center.removePendingNotificationRequests(withIdentifiers: idsToRemove)
}
}
// MARK: - Helpers
private static func isoDay(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private static func thoughtForDate(_ date: Date) -> String {
guard let url = Bundle.main.url(forResource: "thoughts", withExtension: "json"),
let data = try? Data(contentsOf: url),
let quotes = try? JSONDecoder().decode([String].self, from: data),
!quotes.isEmpty
else {
return "Stay steady. Your growth is happening."
}
let calendar = Calendar.current
let comps = calendar.dateComponents([.year, .month, .day], from: date)
let seed =
(comps.year ?? 0) * 10000 +
(comps.month ?? 0) * 100 +
(comps.day ?? 0)
let index = abs(seed) % quotes.count
return quotes[index]
}
}
Then here is my Justonethoughtapp code:
import SwiftUI
import UserNotifications
@main
struct JustOneThoughtApp: App {
@StateObject private var thoughtStore = ThoughtStore()
// MUST match App Store Connect EXACTLY
@StateObject private var subManager =
SubscriptionManager(productIDs: ["Justonethought.monthly"])
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(thoughtStore)
.environmentObject(subManager)
.onAppear {
// Ask for notification permission
NotificationManager.shared.requestPermission()
// Schedule notifications using PRO status
DailyNotifications.scheduleNext30Days(
isPro: subManager.isPro
)
}
}
}
}
final class NotificationManager {
static let shared = NotificationManager()
private init() {}
func requestPermission() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { _, _ in }
}
}
Can anyone advise on this? We distributed promotional trial codes for our app Ask Dolly. These 1-month free trials are set to renew and charge users in March 2026.
A segment of users redeemed the promo codes but never created accounts or opened the app. We don't have their contact information to notify them. Our CEO has directed us to prevent these inactive subscriptions from renewing to avoid charging users who never engaged with the service.
We've downloaded the Subscription and Offer Code Redemption reports from App Store Connect, but cannot map Apple's Subscriber IDs to our user database (we only store Transaction IDs). This prevents us from identifying which specific subscriptions to cancel.
What We Need: Assistance preventing renewals for promotional subscriptions where users have had zero app sessions/opens as of the end of February.
These trials will start to renew on March 3, 2026. We need to resolve this before then to avoid charging inactive users.
Can you help us either:
Cancel subscriptions associated with promo codes that show zero app engagement, or
Provide guidance on how to programmatically identify and cancel these subscriptions?
Topic:
App & System Services
SubTopic:
StoreKit
Not quite but maybe sorta related to the errOSAInternalTableOverflow problem I asked about in a different thread, this one deals with crashes our app gets (and much more frequently lately after recent OS updates (15.7.3) are OK'd by our IT department).
Our app can run multiple jobs concurrently, each in their own NSOperation. Each op creates its own SBApplication instance that controls unique instances of InDesignServer. What I'm seeing recently is lots of crashes happening while multiple ops are calling into ScriptingBridge. Shown at the bottom is one of the stack crawls from one of the threads. I've trimmed all but the last of our code. Other threads have a similar stack crawl.
In searching for answers, Google's AI overview mentions "If you must use multiple threads, ensure that each thread creates its own SBApplication instance…" Which is what we do. No thread can reach another thread's SBApplication instance. Is that statement a lie? Do I need to lock around every ScriptingBridge call (which is going to severely slow things down)?
0 AE 0x1a7dba8d4 0x1a7d80000 + 239828
1 AE 0x1a7d826d8 AEProcessMessage + 3496
2 AE 0x1a7d8f210 0x1a7d80000 + 61968
3 AE 0x1a7d91978 0x1a7d80000 + 72056
4 AE 0x1a7d91764 0x1a7d80000 + 71524
5 CoreFoundation 0x1a0396a64 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 28
6 CoreFoundation 0x1a03969f8 __CFRunLoopDoSource0 + 172
7 CoreFoundation 0x1a0396764 __CFRunLoopDoSources0 + 232
8 CoreFoundation 0x1a03953b8 __CFRunLoopRun + 840
9 CoreFoundation 0x1a03949e8 CFRunLoopRunSpecific + 572
10 AE 0x1a7dbc108 0x1a7d80000 + 246024
11 AE 0x1a7d988fc AESendMessage + 4724
12 ScriptingBridge 0x1ecb652ac -[SBAppContext sendEvent:error:] + 80
13 ScriptingBridge 0x1ecb5eb4c -[SBObject sendEvent:id:keys:values:count:] + 216
14 ScriptingBridge 0x1ecb6890c -[SBCommandThunk invoke:] + 376
15 CoreFoundation 0x1a037594c ___forwarding___ + 956
16 CoreFoundation 0x1a03754d0 _CF_forwarding_prep_0 + 96
17 RRD 0x1027fca18 -[AppleScriptHelper runAppleScript:withSubstitutionValues:usingSBApp:] + 1036
Hello,
I am using CLLocationManager to monitor multiple CLBeaconRegion instances (up to 20). When the app is terminated by the system (not force-quit) and a region enter event occurs, the app is relaunched in the background.
I have two questions:
What is the expected execution time window after relaunch before the app is suspended again?
Is it supported to start short CoreBluetooth operations (e.g., scanning or connecting briefly) within this window?
I understand that force-quitting the app disables background relaunch, so this question applies only to system-terminated apps.
Hello,
I am developing a driver-based application targeting iOS 14+, where users receive time-sensitive trip offers (approximately 10–15 seconds to respond).
We would like to implement behavior similar to approval-based apps (e.g., MyGate-style interaction), with the following requirements:
When the device is locked:
A highly visible notification that allows quick Accept / Decline action.
When the device is unlocked (foreground or background):
A notification that remains prominently visible (sticky-style) at the top of the screen until the user takes action (Accept / Decline) or the offer expires.
Our goal is to ensure the offer remains noticeable and actionable within the short response window.
I would appreciate clarification on the following:
On iOS 14, is there any supported mechanism to present a true full-screen blocking interface while the device is locked (without using CallKit or Critical Alerts entitlement)?
Is there a supported way to make a notification persistent or non-dismissible until the user takes action or the offer expires?
Are there any App Review concerns with presenting a blocking modal immediately after the user interacts with a notification?
We want to ensure full compliance with Apple’s platform guidelines and avoid unsupported or discouraged patterns.
Thank you for your guidance.
Topic:
App & System Services
SubTopic:
Notifications
Tags:
APNS
Notification Center
User Notifications
Hello,
Our team submitted a request for Family Controls entitlements for our main app and four related extensions. It has now been a little over two weeks since submission, and the request is still pending review.
We wanted to check if there are any recommended steps we can take on our end to help move the process forward.
Any guidance or tips from anyone who have recently gone through this process would be greatly appreciated. Thank you.
This is a question as I don't found any related documents or posts anywhere about this. Does anyone know how and when will this "pop up" shown?
Hello Apple Developer Support,
We are observing inconsistent behavior with push notification sounds routing to Bluetooth / external speakers.
Our app sends push notifications with a custom sound file using the sound parameter in the APNs payload. When an iPhone is connected to a Bluetooth speaker or headphones:
On some devices, the notification sound plays through the connected Bluetooth/external speaker.
On other devices, the notification sound plays only through the iPhone’s built-in speaker.
We also tested with native apps like iMessage and noticed similar behavior — in some cases, notification sounds still play through the phone speaker even when Bluetooth is connected.
Media playback (e.g., YouTube or Music) routes correctly to Bluetooth, so the connection itself is functioning properly.
We would like clarification on the following:
Is this routing behavior expected for push notification sounds?
Are notification sounds intentionally restricted from routing to Bluetooth in certain conditions (e.g., device locked, system policy, audio session state)?
Is there any supported way to ensure notification sounds consistently route through connected Bluetooth/external speakers?
The inconsistent behavior across devices makes it difficult to determine whether this is by design or a configuration issue.
Thank you for your guidance.