Push notification adalah pesan dari server yang muncul di HP walaupun aplikasinya sedang ditutup. Di Laundry Bu Rini, begitu Bu Rini memindahkan kartu #13 ke "Siap diambil", HP Mbak Wulan berbunyi: "Pesanan #13 siap diambil". Bayangin Bu Rini mau menitip kabar ke rumah Mbak Wulan di sebuah kompleks. Dia gak bisa masuk sendiri. Dia titip ke satpam kompleks, dan satpam cuma mau mengantar kalau tahu nomor rumahnya dan penghuninya sudah bilang "boleh antar pesan ke saya". Satpam itu layanan push milik Google dan Apple, nomor rumah itu token, dan "boleh" itu izin notifikasi.
🧪 Tebakan cepat: Kode push sudah benar. Mbak Wulan membuka aplikasi lewat Expo Go di HP Android, lalu
getExpoPushTokenAsync()dipanggil. Apa yang terjadi? Jawabannya ada di bagian "Expo Go, development build, dan simulator".
Jalur satu push dari papan order ke HP
| Langkah | Siapa | Yang terjadi |
|---|---|---|
| 1 | Aplikasi | minta izin notifikasi ke pengguna |
| 2 | Aplikasi | minta token: alamat HP ini untuk push |
| 3 | Aplikasi ke server Laravel | kirim token, server menyimpannya per perangkat |
| 4 | Server ke Expo Push Service | "kirim 'Pesanan #13 siap diambil' ke token ini" |
| 5 | Expo ke FCM / APNs | Android lewat FCM (Firebase Cloud Messaging), iOS lewat APNs (Apple Push Notification service) |
| 6 | HP | notifikasi tampil; diketuk, aplikasi membuka Order #13 |
Jadi sebelum bisa mengirim apa pun, aplikasi harus mendapat izin dan token dulu. Tanpa token, server gak tahu HP mana yang dituju.
Izin dan token di aplikasi
npx expo install expo-notifications expo-constants # SDK 57: expo-notifications 57.0.22
Tambahkan "expo-notifications" ke plugins di app.json. Plugin-nya menambah hak aps-environment di iOS, dan library-nya membawa izin POST_NOTIFICATIONS untuk Android.
// src/lib/push.ts
import Constants from 'expo-constants';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function daftarkanPush(): Promise<string | null> {
if (Platform.OS === 'android') {
// Android 13+: dialog izin baru muncul setelah ada minimal satu channel
await Notifications.setNotificationChannelAsync('status-order', {
name: 'Status order',
importance: Notifications.AndroidImportance.HIGH,
});
}
let izin = await Notifications.getPermissionsAsync();
if (!izin.granted && izin.canAskAgain) {
izin = await Notifications.requestPermissionsAsync();
}
if (!izin.granted) return null; // ditolak: aplikasi tetap jalan, cuma tanpa push
const projectId = Constants.expoConfig?.extra?.eas?.projectId ?? Constants.easConfig?.projectId;
const { data } = await Notifications.getExpoPushTokenAsync({ projectId });
return data; // "ExponentPushToken[...]"
}
- Channel adalah kelompok notifikasi di Android 8 ke atas, dengan suara dan tingkat kepentingannya sendiri. Pengguna bisa mematikan "Status order" tanpa mematikan yang lain. Setelah dibuat, aplikasi cuma bisa mengganti nama dan deskripsinya.
- Android 13 ke atas punya izin
POST_NOTIFICATIONS, dan dialognya baru muncul kalau sudah ada channel. Itu sebabnya channel dibuat paling dulu. Android 12 ke bawah gak bertanya sama sekali. - iOS selalu bertanya, dan cuma sekali (aturan izin di pelajaran platform). Mintalah saat Mbak Wulan baru membuat order, dengan kalimat "Mau dikabari saat cucian siap?", bukan begitu aplikasi dibuka.
- iOS provisional:
requestPermissionsAsync({ ios: { allowAlert: true, allowBadge: true, allowSound: true, allowProvisional: true } })gak memunculkan dialog. Notifikasi masuk diam-diam ke Notification Center, dan pengguna memutuskan di sana. Hasilnyastatustetap'undetermined', jadi periksaizin.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL. - Token Expo vs token native:
getDevicePushTokenAsync()memberi token FCM (Android) atau APNs (iOS).getExpoPushTokenAsync()mengirim token native itu ke server Expo dan mendapatExponentPushToken[...], satu bentuk untuk dua platform.
Server menyimpan token dan mengirim push
Aplikasi mengirim token ke POST /api/me/push-tokens dengan {"token":"ExponentPushToken[...]","platform":"android"} dan token login Bearer. Server menyimpannya satu baris per perangkat: kolom token unik, plus user_id dan platform. Mbak Wulan punya HP dan tablet: dua baris. HP dipakai akun lain: baris yang sama pindah user_id.
Saat #13 jadi "Siap diambil", server mengirim ke https://exp.host/--/api/v2/push/send (sampai 100 pesan per request):
// di server papan order (Laravel)
$res = Http::acceptJson()->post('https://exp.host/--/api/v2/push/send', [[
'to' => 'ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]',
'title' => 'Laundry Bu Rini',
'body' => 'Pesanan #13 siap diambil',
'data' => ['url' => '/order/13'],
'channelId' => 'status-order',
'sound' => 'default',
]]);
{ "data": [{ "status": "ok", "id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" }] }
- Jawaban ini push ticket.
"ok"cuma berarti Expo sudah menerima pesannya, belum berarti sampai di HP. - Sekitar 15 menit kemudian server memanggil
POST https://exp.host/--/api/v2/push/getReceiptsdengan{"ids":["XXXXXXXX-..."]}. Jawabannya push receipt per id:{"status":"ok"}, atau error dengan"details":{"error":"DeviceNotRegistered"}. Receipt dihapus setelah 24 jam. DeviceNotRegisteredartinya HP itu gak bisa menerima push lagi (aplikasinya dihapus, misalnya). Hapus baris tokennya dan berhenti mengirim. Google dan Apple menentukan kapan ini muncul, jadi menghapus aplikasi lalu langsung mengirim belum tentu memunculkannya.- Error lain:
MessageTooBig(payload lebih dari 4.096 byte),MessageRateExceeded, dan HTTP 429 atau 5xx yang dicoba lagi dengan jeda yang makin panjang. - Supaya Expo bisa meneruskan ke FCM dan APNs, kunci FCM V1 dan kunci APNs diunggah ke EAS (
eas credentials). APNs butuh akun Apple Developer berbayar.
Mengetuk notifikasi membuka Order #13
data.url berisi /order/13, alamat yang sama dengan layar src/app/order/[id].tsx. Di layout akar:
// src/app/_layout.tsx (bagian push)
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true, shouldShowList: true, shouldPlaySound: true, shouldSetBadge: false,
}),
});
function bukaDariNotifikasi(n: Notifications.Notification) {
const url = n.request.content.data?.url;
if (typeof url === 'string' && url.startsWith('/order/')) router.push(url); // cuma alamat yang dikenal
}
// di dalam RootLayout
useEffect(() => {
const terakhir = Notifications.getLastNotificationResponse(); // aplikasi dibuka DARI notifikasi
if (terakhir) bukaDariNotifikasi(terakhir.notification);
const sub = Notifications.addNotificationResponseReceivedListener((r) => bukaDariNotifikasi(r.notification));
return () => sub.remove();
}, []);
getLastNotificationResponse()menangani aplikasi yang tadinya tertutup dan dibuka lewat ketukan. Listener menangani aplikasi yang masih hidup di background.setNotificationHandlermengatur notifikasi yang datang saat aplikasi sedang terbuka. Di SDK 57, tanpa handler ini notifikasinya gak tampil sama sekali.- Halaman
/order/13tetap memeriksa id-nya ke server. Detailnya di pelajaran deep linking.
Expo Go, development build, dan simulator
Sejak SDK 53, push di Expo Go untuk Android dihapus. getExpoPushTokenAsync() di Expo Go Android melempar error: expo-notifications: Android Push notifications (remote notifications) functionality provided by expo-notifications was removed from Expo Go with the release of SDK 53. Use a development build instead of Expo Go. Itu jawaban tebakannya. Di iOS, Expo Go cuma memberi peringatan, tapi tetap uji dengan development build supaya izin, channel, dan nama aplikasinya milikmu sendiri. Notifikasi lokal (dijadwalkan aplikasi sendiri) masih jalan di Expo Go.
Push bisa diuji di HP asli, emulator Android yang punya Google Play services, dan Simulator iOS dari Xcode 14 ke atas. Pengecekan Device.isDevice di tutorial lama sudah gak perlu.
⚠️ Jebakan yang sering ketemu
- Minta izin di Android 13 sebelum ada channel. Dialognya gak muncul. Panggil
setNotificationChannelAsyncdulu. - Mengira ticket
"ok"berarti terkirim. Cek receipt sekitar 15 menit kemudian, dan hapus token yangDeviceNotRegistered. - Satu token per akun. Token itu milik perangkat. Simpan per perangkat, dengan
tokenunik. - Lupa
setNotificationHandler. Notifikasi yang datang saat aplikasi terbuka hilang tanpa jejak. router.push(data.url)tanpa diperiksa. Isidatadatang dari luar aplikasi. Terima cuma alamat yang kamu kenal.
TL;DR: Push "Pesanan #13 siap diambil" butuh izin dan token dulu: Android lewat FCM, iOS lewat APNs, dan Expo Push Service membungkus keduanya dengan token ExponentPushToken[...]. Di Android buat channel dulu (status-order), karena dialog izin Android 13 baru muncul setelah ada channel; iOS bertanya sekali, atau pakai provisional tanpa dialog (ios.status PROVISIONAL). Server menyimpan token per perangkat, mengirim ke exp.host/--/api/v2/push/send (ticket "ok" berarti diterima Expo), lalu membaca receipt sekitar 15 menit kemudian dan menghapus token DeviceNotRegistered. data: { url: '/order/13' } plus getLastNotificationResponse() dan addNotificationResponseReceivedListener membuka Order #13 saat diketuk, dan setNotificationHandler wajib untuk notifikasi saat aplikasi terbuka. Push Android gak jalan di Expo Go sejak SDK 53: pakai development build.