brainstorming
brainstorming
You MUST use this before any creative work - creating features, building components, adding functionality, modifying behavior, or planning anything new, in software or out of it (a talk, a business, a renovation).
mobile-push-notificationsUse this skill when the user says 'push notification', 'APNs', 'FCM', 'remote notification', 'push token', 'notification payload', 'notification channel'. This skill enforces platform-specific push notification patterns: permission flow, token management, payload structure, foreground/background handling, and notification channels. Applies to iOS, Android, …
Independent GitHub snapshot · Automated classification · Original documentation governs
Implement push notifications with correct permission flow, token management, payload design, and foreground/background handling across all mobile platforms.
User request includes: push notification, APNs, FCM, remote notification, push token, notification payload, notification channel.
A markdown document containing permission flow, token registration and refresh, payload structure (alert, data, silent), foreground/background handling, notification channels (Android), and platform-specific considerations.
Code-first. One code block per platform (Swift, Kotlin, Dart/TS) with full implementation. Summarize platform divergence points in bullet list. No preamble. No postamble. No explanations.
4096 tokens
Which push service?
├── iOS-only → APNs directly (simpler, no extra dependency)
├── Android-only → FCM directly (required for Play Store apps)
├── iOS + Android → FCM (unified, single server endpoint)
│ └── FCM handles APNs relay for iOS automatically
├── Cross-platform (Flutter/RN) → Firebase Cloud Messaging SDK
│ └── flutter-fire / react-native-firebase handles both platforms
└── Need advanced analytics → Firebase + BigQuery / custom analytics
What type of notification?
├── User-facing alert (new message, order update, reminder)
│ ├── iOS: alert payload with aps.alert, badge, sound
│ └── Android: notification channel with importance level
├── Silent data sync (content sync, cache refresh)
│ ├── iOS: content-available:1, no alert/badge/sound
│ ├── Android: data-only payload, no notification key
│ └── Triggers background processing (30s iOS, variable Android)
├── Rich notification (image, actions, reply)
│ ├── iOS: UNNotificationServiceExtension for media download
│ └── Android: BigPictureStyle, InboxStyle, action buttons
└── Critical alert (health, safety, security)
└── iOS: critical alert entitlement + critical:1 in payload
When to request permission?
├── On first launch (preferred: explain why first)
│ ├── Show custom UI explaining notification benefits
│ ├── Then call system permission dialog
│ └── If denied: never prompt again (iOS), can re-prompt (Android)
├── On value moment (after user completes key action)
│ ├── "Enable notifications for order updates?"
│ └── Higher grant rate but later token registration
└── Proactive re-prompt (Android only)
└── Use shouldShowRequestPermissionRationale to check
App Launch → Explain why notifications → Request Authorization → Grant → Register Token
Deny → Handle restricted state (show settings link)
import UserNotifications
import UIKit
class NotificationService: NSObject {
static let shared = NotificationService()
func requestPermission() {
// Show custom pre-permission dialog first for higher grant rate
let center = UNUserNotificationCenter.current()
center.requestAuthorization(options: [.alert, .sound, .badge, .criticalAlert]) { granted, error in
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
} else {
// Log denial — may want to show settings redirect later
}
}
}
func getPermissionStatus(completion: @escaping (UNAuthorizationStatus) -> Void) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
completion(settings.authorizationStatus)
}
}
}
class NotificationHelper(private val context: Context) {
companion object {
const val CHANNEL_ORDERS = "orders"
const val CHANNEL_CHAT = "chat"
const val CHANNEL_PROMO = "promotions"
}
fun createChannels() {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channels = listOf(
NotificationChannel(CHANNEL_ORDERS, "Orders", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Order updates and status changes"
enableVibration(true)
setShowBadge(true)
},
NotificationChannel(CHANNEL_CHAT, "Chat Messages", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Direct messages from other users"
enableLights(true)
},
NotificationChannel(CHANNEL_PROMO, "Promotions", NotificationManager.IMPORTANCE_LOW).apply {
description = "Sales, offers, and recommendations"
setShowBadge(false)
},
)
channels.forEach { manager.createNotificationChannel(it) }
}
// Android 13+ requires runtime permission (POST_NOTIFICATIONS)
fun requestPermission(activity: Activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST
)
}
}
}
import 'package:firebase_messaging/firebase_messaging.dart';
class PushService {
final _messaging = FirebaseMessaging.instance;
Future<void> setup() async {
// Request permission (iOS) / no-op on Android
final settings = await _messaging.requestPermission(
alert: true,
badge: true,
sound: true,
criticalAlert: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
// Get token
final token = await _messaging.getToken();
await _registerToken(token!);
// Listen for token refresh
_messaging.onTokenRefresh.listen(_registerToken);
// Foreground messages
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// App opened from notification (background state)
FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap);
// App opened from notification (killed state)
final initial = await _messaging.getInitialMessage();
if (initial != null) _handleNotificationTap(initial);
}
}
Future<void> _registerToken(String token) async {
await api.post('/push/register', {'token': token, 'platform': 'mobile'});
}
void _handleForegroundMessage(RemoteMessage message) {
// Show local notification or update in-app UI
}
void _handleNotificationTap(RemoteMessage message) {
// Navigate based on deep link in payload
final deepLink = message.data['deep_link'] as String?;
if (deepLink != null) navigatorKey.currentState?.pushNamed(deepLink);
}
}
import messaging from '@react-native-firebase/messaging';
import notifee, { AndroidImportance } from '@notifee/react-native';
class PushService {
async setup() {
// Request permission (iOS)
const authStatus = await messaging().requestPermission();
const enabled = authStatus === messaging.AuthorizationStatus.AUTHORIZED
|| authStatus === messaging.AuthorizationStatus.PROVISIONAL;
if (enabled) {
// Create notification channels (Android)
await notifee.createChannel({
id: 'orders',
name: 'Orders',
importance: AndroidImportance.HIGH,
});
await notifee.createChannel({
id: 'chat',
name: 'Chat Messages',
importance: AndroidImportance.DEFAULT,
});
// Get token
const token = await messaging().getToken();
await api.post('/push/register', { token, platform: 'mobile' });
// Token refresh
messaging().onTokenRefresh(async (newToken) => {
await api.post('/push/register', { token: newToken });
});
// Foreground messages — show local notification
messaging().onMessage(async (remoteMessage) => {
await notifee.displayNotification({
title: remoteMessage.notification?.title,
body: remoteMessage.notification?.body,
android: { channelId: remoteMessage.data?.channel || 'default' },
ios: {}, // iOS shows system notification automatically
});
});
// Background message handler
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
await notifee.displayNotification({ ... });
});
// Notification tap — background state
messaging().onNotificationOpenedApp((remoteMessage) => {
navigate(remoteMessage.data?.deep_link);
});
// Notification tap — killed state
const initial = await messaging().getInitialNotification();
if (initial) navigate(initial.data?.deep_link);
}
}
}
// AppDelegate
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
api.registerToken(token, platform: "ios")
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
// Handle — usually simulator or no network
logger.error("Push registration failed: \(error.localizedDescription)")
}
// FirebaseMessagingService
class MyFirebaseService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
super.onNewToken(token)
// Token changed (rotated, restored, new install)
api.registerToken(token, "android")
}
}
const admin = require('firebase-admin');
admin.initializeApp({ credential: admin.credential.applicationDefault() });
async function sendPush(userId, title, body, data = {}) {
const tokens = await db.getUserTokens(userId);
const message = {
tokens, // Up to 500 per call
notification: { title, body },
data: { ...data, click_action: 'FLUTTER_NOTIFICATION_CLICK' },
android: {
notification: {
channelId: data.channel || 'default',
priority: 'high',
sound: 'default',
},
},
apns: {
payload: {
aps: {
alert: { title, body },
badge: 1,
sound: 'default',
'content-available': 1,
interruptionLevel: data.critical ? 'critical' : 'active',
},
},
},
};
const response = await admin.messaging().sendEachForMulticast(message);
// Handle delivery failures — remove stale tokens
response.responses.forEach((resp, idx) => {
if (resp.error?.code === 'messaging/registration-token-not-registered') {
db.removeToken(tokens[idx]);
}
});
}
const apn = require('apn');
const apnProvider = new apn.Provider({
token: {
key: fs.readFileSync('./AuthKey.p8'),
keyId: 'KEY_ID',
teamId: 'TEAM_ID',
},
production: true, // false for sandbox
});
async function sendAPNs(deviceToken, title, body, data = {}) {
const notification = new apn.Notification();
notification.alert = { title, body };
notification.badge = 1;
notification.sound = 'default';
notification.payload = data;
notification.topic = 'com.example.app';
notification.pushType = 'alert';
notification.interruptionLevel = data.critical ? 'critical' : 'active';
const result = await apnProvider.send(notification, deviceToken);
if (result.failed.length > 0) {
result.failed.forEach(f => {
if (f.error?.status === 410 || (f.error?.status === 400 && f.error?.reason === 'BadDeviceToken')) {
db.removeToken(f.device); // Token expired
}
});
}
}
{
"aps": {
"alert": {
"title": "Order Shipped",
"body": "Your order #1234 has shipped",
"subtitle": "Arriving tomorrow"
},
"badge": 3,
"sound": "default",
"thread-id": "order-1234",
"category": "order_update"
},
"deep_link": "orders/1234",
"image_url": "https://example.com/order.png"
}
{
"aps": {
"content-available": 1
},
"type": "order_sync",
"order_ids": ["1234", "1235"],
"silent": true
}
{
"to": "device_token",
"priority": "high",
"notification": {
"title": "Order Shipped",
"body": "Your order #1234 has shipped",
"android_channel_id": "orders",
"click_action": "OPEN_ORDER",
"image": "https://example.com/order.png"
},
"data": {
"type": "order_update",
"order_id": "1234",
"deep_link": "orders/1234"
}
}
// NotificationService.swift (added as separate target)
class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard let attachment = request.content.userInfo["image_url"] as? String,
let url = URL(string: attachment) else {
contentHandler(request.content)
return
}
// Download attachment (30s timeout)
let task = URLSession.shared.downloadTask(with: url) { location, _, error in
if let location = location {
let data = try? Data(contentsOf: location)
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(url.lastPathComponent)
try? data?.write(to: tempURL)
if let attachment = try? UNNotificationAttachment(identifier: "image", url: tempURL) {
let content = request.content.mutatingCopy()
content.attachments = [attachment]
contentHandler(content)
return
}
}
contentHandler(request.content)
}
task.resume()
}
}
val bigPictureStyle = NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null) // Hide large icon when expanded
val notification = NotificationCompat.Builder(context, CHANNEL_ORDERS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Order Shipped")
.setContentText("Your order #1234 has shipped")
.setStyle(bigPictureStyle)
.addAction(R.drawable.ic_track, "Track", trackPendingIntent)
.addAction(R.drawable.ic_contact, "Contact", contactPendingIntent)
.build()
// Register categories at launch
let category = UNNotificationCategory(
identifier: "order_update",
actions: [
UNNotificationAction(identifier: "TRACK", title: "Track", options: .foreground),
UNNotificationAction(identifier: "CONTACT", title: "Contact Support", options: .foreground),
UNTextInputNotificationAction(identifier: "REPLY", title: "Reply", options: [])
],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([category])
// Handle action
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completion: @escaping () -> Void) {
switch response.actionIdentifier {
case "TRACK": navigateToOrder()
case "REPLY": handleReply(response)
default: break
}
completion()
}
val replyLabel = "Reply"
val remoteInput = RemoteInput.Builder("reply_text")
.setLabel(replyLabel)
.build()
val replyAction = NotificationCompat.Action.Builder(
R.drawable.ic_reply, "Reply", replyPendingIntent
).addRemoteInput(remoteInput).build()
// Handle in BroadcastReceiver
class ReplyReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val reply = RemoteInput.getResultsFromIntent(intent)?.getString("reply_text")
if (reply != null) {
api.sendReply(orderId, reply)
}
}
}
// Set thread-id in payload
{
"aps": {
"alert": { ... },
"thread-id": "order-1234"
}
}
// Or in code
let summary = UNNotificationSummarySetting...
val groupKey = "orders"
val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ORDERS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("3 new orders")
.setGroup(groupKey)
.setGroupSummary(true)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.build()
val childNotification = NotificationCompat.Builder(context, CHANNEL_ORDERS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(order.title)
.setContentText(order.status)
.setGroup(groupKey)
.build()
When remote push fails, use local notifications as fallback:
Future<void> showLocalNotification({
required String title,
required String body,
String? channel = 'default',
String? payload,
}) async {
const androidDetails = AndroidNotificationDetails(
channel ?? 'default', 'Default',
importance: Importance.high,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails();
await _localNotificationsPlugin.show(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
title,
body,
NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: payload,
);
}
onNewTokenSendResponse failures, remove stale tokenscontent-available throttling per app. Too many silent pushes = droppedonMessage to show custom in-app UI insteadswcutil or Xcode > Simulate Push Notification with .apns filereferences/apns-guide.md — APNs Guidereferences/fcm-guide.md — FCM Guidereferences/local-notifications.md — Local Notificationsreferences/payload-design.md — Payload Designreferences/permission-flow.md — Permission Flowreferences/push-notifications.md — Push Notifications SetupAfter push notification setup, hand off to:
mobile/universal/deep-linking — Notification tap navigation, deep link handlingmobile/universal/analytics — Push notification tracking, conversion funnelsmobile/universal/performance — Push throttling, silent push budgetingmobile/universal/security — Token security, APNs certificate managementmobile/universal/testing — Push notification testing scenariosmobile/ios — APNs, notification service extension, critical alertsmobile/android — FCM, notification channels, direct reply