Screen Time API iOS di 2026: yang tidak diceritakan dokumentasi Apple

FamilyControls, DeviceActivity, dan extension shield, dari pengalaman merilis aplikasi blokir. Oleh Thomas Willheim · 30 September 2026

Agustus dan September 2026 aku habiskan untuk merilis aplikasi blokir di iOS. Aplikasinya memakai framework Screen Time (FamilyControls, DeviceActivity, ManagedSettings, dan ManagedSettingsUI) dari React Native, lewat Expo SDK 57 dan library react-native-device-activity (0.6.1). Dokumentasi Apple menjelaskan apa itu setiap tipe. Tapi hampir tidak ada penjelasan soal bagaimana perilakunya di HP sungguhan, semalaman, di iOS 26.

Ini daftar yang dulu aku harap sudah ada. Perilaku yang aku lihat di perangkat tapi tidak bisa aku temukan dokumentasinya diberi label teramati; belum tentu berlaku di semua versi iOS. Hal yang khusus untuk library React Native diberi label library; lewati saja kalau kamu menulis Swift langsung.

1. Komponen-komponennya

“Screen Time API” itu sebenarnya empat framework dan, dalam praktiknya, tiga app extension:

Justru extension-extension inilah intinya. Saat pengguna kehabisan waktu, aplikasiku biasanya sedang di-suspend atau sudah dimatikan, jadi extension monitor-lah yang memasang shield lagi. Rancang semuanya seolah-olah proses aplikasimu tidak akan pernah ada saat sesuatu terjadi.

2. Entitlement: satu permintaan per bundle ID

Entitlement Family Controls (Distribution) disetujui per bundle ID. Dengan tiga extension, berarti empat permintaan. Punyaku:

Setelah disetujui, aktifkan capability Family Controls (Distribution) di setiap App ID di developer portal, lalu buat satu App Group dan pasang ke keempatnya. Kelewatan satu saja, target itu tidak akan bisa di-provision. Persetujuannya tidak instan, jadi ajukan keempat permintaan di hari pertama, sebelum kamu menulis banyak kode.

File entitlements setiap extension akhirnya sama persis:

<key>com.apple.developer.family-controls</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
  <string>group.com.honorfit.blocker</string>
</array>

Di Expo/EAS, layanan build cuma membuat provisioning profile untuk extension yang dikenalnya. Aku mendeklarasikan setiap target, beserta entitlement-nya, di bawah extra.eas.build.experimental.ios.appExtensions di app.json:

"appExtensions": [
  {
    "bundleIdentifier": "com.honorfit.blocker.ShieldConfiguration",
    "targetName": "ShieldConfiguration",
    "entitlements": {
      "com.apple.developer.family-controls": true,
      "com.apple.security.application-groups": ["group.com.honorfit.blocker"]
    }
  }
  // ...same for ShieldAction and ActivityMonitorExtension
]

3. App Group adalah satu-satunya jalur

Aplikasimu dan extension-nya adalah proses terpisah yang hampir tidak pernah jalan bersamaan. Satu-satunya yang mereka bagi adalah container App Group, biasanya lewat UserDefaults(suiteName:). Semua yang dibutuhkan extension (pilihan aplikasi yang tersimpan, teks shield, apa yang harus dilakukan saat threshold terpicu) harus ditulis di sana lebih dulu.

react-native-device-activity memakai cara ini sepenuhnya. Aplikasi menulis daftar “aksi” di bawah key seperti actions_for_{activity}_{callback}_{event}, dan extension monitor mencarinya saat callback terpicu. Efek sampingnya berguna: kamu bisa mengubah apa yang dilakukan monitor yang sudah berjalan tanpa me-restart-nya. Extension-nya juga memanggil CFPreferencesAppSynchronize sebelum membaca, supaya mereka melihat tulisan terbaru dari aplikasi:

let appGroup = Bundle.main.object(
  forInfoDictionaryKey: "REACT_NATIVE_DEVICE_ACTIVITY_APP_GROUP") as? String
var userDefaults = UserDefaults(suiteName: appGroup)

// in each callback:
CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)
if let actions = userDefaults?.array(forKey: triggeredBy) { /* run them */ }

4. Token itu buram, dan soal pemilih aplikasi

Pemilih aplikasi (picker) memberi aplikasimu ApplicationToken, ActivityCategoryToken, dan WebDomainToken, dan tidak ada yang lain. Memang sengaja dirancang begitu: aplikasi tidak bisa mengubah token kembali jadi “Instagram”. Yang bisa dilakukan cuma menghitungnya, jadi layar daftar blokir di aplikasiku menampilkan “3 aplikasi, 1 kategori”. Itulah seluruh model privasinya, dan model yang bagus: aplikasiku benar-benar tidak tahu apa yang kamu blokir.

Pemilih aplikasi Durasi Layar di iOS: daftar kategori aplikasi, dengan Kesehatan & Kebugaran dibuka untuk menampilkan aplikasinya
FamilyActivityPicker buatan Apple. Pengguna melihat nama dan ikon aplikasi; aplikasimu cuma menerima token buram.

Extension shield berbeda. Di sana, sistem memberimu Application untuk aplikasi yang sedang diblokir, dan aku memakai localizedDisplayName-nya di teks shield. Aplikasinya sendiri cuma pernah melihat token.

Dua catatan praktis:

<DeviceActivitySelectionSheetViewPersisted
  familyActivitySelectionId="distracting-apps"
  includeEntireCategory={false}   // must be present, or nothing preloads
  onSelectionChange={(e) => setCounts(e.nativeEvent)}
  onDismissRequest={() => setPickerVisible(false)}
/>

library Satu lagi: blockSelection di library ini menambahkan token ke daftar blokir yang persisten. Setelah pengguna mengubah pilihannya, aplikasi yang sudah dihapus dari pilihan tetap diblokir. Jadi setiap kali, aku memanggil resetBlocks() dulu, lalu memblokir ulang pilihan yang sekarang.

5. Mengukur pemakaian dengan event threshold

HonorFit memberimu saldo menit yang cuma berkurang selama aplikasi yang diblokir ada di layar. DeviceActivity tidak punya panggilan “kasih tahu sudah berapa lama dipakai”; yang ada adalah event threshold yang terpicu sekali saat total pemakaian dari suatu pilihan mencapai durasi tertentu. Jadi aku mendaftarkan satu event untuk setiap 15 detik pemakaian, dengan nama dalam satuan menit: used-0.25, used-0.5, … sampai sebesar saldonya. Pecahan seperempat itu eksak dalam floating point biner, jadi namanya tetap utuh saat bolak-balik lewat Number(). Di balik library-nya, ini API Apple biasa:

let schedule = DeviceActivitySchedule(
  intervalStart: DateComponents(hour: 9, minute: 45, second: 0),  // see section 6
  intervalEnd: DateComponents(hour: 23, minute: 59, second: 59),
  repeats: false
)
let event = DeviceActivityEvent(
  applications: selection.applicationTokens,
  categories: selection.categoryTokens,
  webDomains: selection.webDomainTokens,
  threshold: DateComponents(minute: 0, second: 15),
  includesPastActivity: false          // iOS 17.4+
)
try DeviceActivityCenter().startMonitoring(
  DeviceActivityName("honorfit-distracting-apps-1790000000000"),
  during: schedule,
  events: [DeviceActivityEvent.Name("used-0.25"): event]  // ...one per step
)

includesPastActivity: false (nilai default-nya) berarti pemakaian sebelum event didaftarkan tidak dihitung. Ini penting untuk trik jadwal di bagian berikutnya.

Saat event terakhir terpicu, extension monitor memasang shield lagi dan mengirim notifikasi “waktu habis”. Setiap event 15 detik memperbarui satu notifikasi dengan identifier tetap, jadi notifikasinya diganti di tempat dan berfungsi sebagai hitung mundur kasar yang kurang lebih real-time. interruptionLevel = .passive mengirimnya diam-diam ke Pusat Notifikasi tanpa banner.

library Jangan pakai garis bawah di nama event atau activity. Library ini menyimpan event yang sudah terpicu di bawah events_{activity}_{callback}_{eventName} dan membacanya kembali dengan memecah string di _. Event bernama used_7 kembali sebagai used, dan tidak pernah ada yang cocok. Gejalanya: saldo yang tidak pernah berkurang.

6. Jadwal tidak mulai saat yang kamu kira

Kalau event threshold-mu diam-diam tidak pernah terpicu, kemungkinan besar penyebabnya ini.

“Sekarang” itu sudah masa lalu

teramati DeviceActivitySchedule yang tidak berulang dicocokkan dengan komponen tanggal, dan monitor menjalankan interval berikutnya yang cocok. Kalau kamu mengoper jam saat ini sebagai intervalStart, saat framework mengevaluasinya, momen itu sudah lewat. Kecocokan berikutnya adalah besok, dan hari ini tidak ada yang terpicu: tidak ada threshold, tidak ada callback, tidak ada error. Solusinya, mundurkan waktu mulainya supaya “sekarang” ada di dalam interval:

const now = new Date();
const backdated = new Date(now.getTime() - 15 * 60_000);
const intervalStart =
  backdated.getDate() === now.getDate()
    ? { hour: backdated.getHours(), minute: backdated.getMinutes(), second: backdated.getSeconds() }
    : { hour: 0, minute: 0, second: 0 };   // don't wrap past midnight

await startMonitoring(activityName,
  { intervalStart, intervalEnd: { hour: 23, minute: 59, second: 59 }, repeats: false },
  events);

Memundurkan waktu mulai tidak akan membuat pengguna “membayar” scroll yang sebelumnya, karena includesPastActivity: false. Cara ini juga menjaga interval di atas batas minimum Apple: jadwal yang lebih pendek dari 15 menit ditolak, dan itu menjebak siapa pun yang memulai sesi tepat sebelum tengah malam.

Jadwal yang mencakup “sekarang” langsung mulai

teramati Sisi sebaliknya: jadwal 00:00–23:59:59 mencakup waktu saat ini, jadi jadwal itu mulai sekarang, bukan di tengah malam berikutnya. Ada dua cara yang aku temukan untuk menjadwalkan besok:

teramati Jangan pakai komponen tahun/bulan/hari lengkap di jadwal untuk menargetkan tanggal tertentu. Callback extension jadi tidak bisa diandalkan saat aku mencobanya, dan itu sesuai dengan laporan di Apple Developer Forums (thread 729841). Cukup pakai jam/menit/detik.

Satu catatan: perilaku seputar tengah malam ini cuma muncul di perangkat, semalaman. Aku sudah memasang pengaman di sekitarnya, tapi belum mengamati tengah malam sungguhan sebanyak yang aku mau, jadi anggap bagian ini sebagai “apa yang aku lihat”, bukan spesifikasi.

7. stopMonitoring memicu intervalDidEnd

teramati Memanggil stopMonitoring, atau memanggil startMonitoring dengan nama monitor yang sedang berjalan, membuat iOS memanggil intervalDidEnd di extension-mu seolah-olah jadwalnya berakhir dengan sendirinya. Kalau intervalDidEnd-mu memasang shield lagi sebagai jaring pengaman (punyaku begitu, supaya monitor tidak bisa kedaluwarsa dan membiarkan aplikasi tanpa blokir), maka setiap kali aplikasi menghentikan monitor, extension-nya sempat memblokir semuanya sebentar.

Seorang pengguna melaporkan gejalanya: Spotify berhenti setiap kali mereka membuka HonorFit. Menghentikan monitor membuat extension memblokir pilihan aplikasi sesaat, yang membuat Spotify di-suspend, sebelum aplikasinya mencabut shield lagi.

Ada dua perbaikan, dan dua-duanya perlu. Pertama, hapus aksi extension sebelum menghentikan monitor, supaya tidak ada yang bisa dijalankan:

function stopActivities(names: string[]): void {
  for (const name of names) RNDA.cleanUpAfterActivity(name); // clear actions first
  RNDA.stopMonitoring(names);                                // then stop
}

Kedua, beri setiap aksi intervalDidEnd dan intervalDidStart timestamp “jangan sebelum” di batas jadwal yang sebenarnya, dikurangi satu menit sebagai kelonggaran. Extension mengeceknya sebelum menjalankan apa pun:

// TypeScript: register the safety net
RNDA.configureActions({
  activityName,
  callbackName: 'intervalDidEnd',
  actions: [{
    type: 'blockSelection',
    familyActivitySelectionId: 'distracting-apps',
    neverTriggerBefore: new Date(intervalEndMs - 60_000),
  }],
});

// Swift, in the DeviceActivityMonitor extension
if let neverTriggerBefore = neverTriggerBefore {
  let now = Date().timeIntervalSince1970 * 1000
  if now < neverTriggerBefore { return false }   // skip this action
}

Dengan Swift biasa: simpan waktu berakhir yang sebenarnya di App Group dan buat intervalDidEnd langsung keluar kalau dipanggil terlalu awal.

8. Threshold hantu di iOS 26

Di iOS 26.x, eventDidReachThreshold kadang terpicu tanpa pemakaian sama sekali: tepat setelah dijadwalkan, atau saat HP yang lama terkunci diangkat atau dicolok ke charger. Apple sudah mengakuinya di beberapa thread forum developer (809410, 811305, 814559). Katanya sudah diperbaiki di salah satu beta 26.5, tapi orang-orang tetap melaporkannya setelah itu. Di aplikasiku gejalanya “waktu habis” setelah HP ada di saku, karena extension memasang shield lagi dan saldonya terkuras sampai nol.

Kamu tidak bisa mencegah event-nya terpicu, tapi kamu bisa menolak untuk memercayainya. Dua fakta soal threshold yang asli sudah menyelesaikan sebagian besar masalahnya:

  1. Pemakaian tidak bisa lebih cepat dari jam dinding. used-7 tidak mungkin terpicu secara sah kurang dari 7 menit setelah monitor dipasang. Setiap aksi threshold membawa neverTriggerBefore = armedAt + N minutes − 5s, jadi extension melewati pemicuan yang terlalu cepat untuk masuk akal.
  2. Pemakaian asli datang sedikit demi sedikit; hantu datang sekaligus. Saat aplikasi membaca kembali event yang sudah terpicu, aplikasi menganggapnya hantu kalau threshold terendah dan tertinggi terpicu dalam selang sekitar 3 detik tapi mencakup pemakaian satu menit atau lebih.
// Guard 1: drop thresholds that fired before they could have been reached
if (at < startedAt + minutes * 60_000 - 5_000) continue;

// Guard 2: lowest and highest fired in the same instant, a minute or more apart
if (highest.minutes - lowest.minutes >= 1 &&
    Math.abs(highest.at - lowest.at) <= 3_000) {
  return { usedMinutes: 0, partialMs: 0 };   // phantom: ignore the lot
}

Pengaman kedua jalan di aplikasi, bukan di extension, jadi hantu tetap bisa memasang shield. Tapi saldonya selamat, dan saat aplikasi dibuka lagi, shield-nya dicabut. Mendeteksi lonjakan di dalam extension butuh Swift kustom, yang belum aku tulis.

teramati Ada masalah terkait yang arahnya kebalikan: threshold yang seharusnya terpicu tapi tidak. Dengan pemakaian yang keluar-masuk di iOS 26, aku melihat threshold 15 detik pertama dari monitor baru terpicu tepat waktu, sementara yang berikutnya digabung atau hilang sama sekali (used-0.25 terpicu, used-0.5 dan seterusnya tidak pernah). Solusiku: setiap kali aplikasi masuk ke background, monitor apa pun yang sudah sempat mengukur pemakaian dianggap basi, lalu aku pasang monitor baru untuk sisa saldonya. Akibatnya, sampai 15 detik pemakaian per restart tidak terhitung, dan aku tidak masalah dengan itu.

9. Shield tidak bisa membuka aplikasimu

Desain yang paling jelas adalah tombol di shield bertuliskan “Dapatkan waktu lagi” yang membuka aplikasimu. Tidak ada cara resmi untuk melakukannya; Apple DTS bilang tidak ada API yang memungkinkan extension shield meluncurkan aplikasi induknya. ShieldActionDelegate menjawab tekanan tombol dengan ShieldActionResponse (.none, .close, atau .defer), dan tidak satu pun yang membuka apa-apa.

teramati Trik yang akan kamu temukan (NSExtensionContext().open(url) dari extension ShieldAction, yang di library ini tersedia sebagai openUrl) tidak berpengaruh apa-apa di iOS versi sekarang saat aku mengujinya di perangkat pada September 2026. Tombolnya cuma menutup shield. Mengirim notifikasi dari ketukan tombol juga tidak bisa diandalkan menurut pengalamanku.

Yang aku pakai di aplikasi: shield-nya punya satu tombol “Tutup”, dan extension ShieldConfiguration mengirim notifikasi lokal setiap kali shield ditampilkan. Pengguna mengetuk banner-nya dan masuk ke aplikasi. Aplikasi menulis payload notifikasinya ke App Group lebih dulu, dan menutup notifikasinya saat aplikasi masuk ke foreground. iOS bisa meminta konfigurasi shield beberapa kali per tampilan, jadi pengirimannya di-debounce:

func postShieldShownNotification(placeholders: [String: String?]) {
  guard let config = userDefaults?.dictionary(forKey: "honorfitShieldShownNotification"),
        let payload = config["payload"] as? [String: Any] else { return }

  let minInterval = config["minIntervalSeconds"] as? Double ?? 30
  let now = Date().timeIntervalSince1970
  let lastSentAt = userDefaults?.double(forKey: "honorfitShieldShownNotificationLastSentAt") ?? 0
  if now - lastSentAt < minInterval { return }
  userDefaults?.set(now, forKey: "honorfitShieldShownNotificationLastSentAt")

  // builds a UNMutableNotificationContent and adds a UNNotificationRequest
  // with a fixed identifier, so repeats replace rather than stack
  sendNotification(contents: payload, placeholders: placeholders)
}

class ShieldConfigurationExtension: ShieldConfigurationDataSource {
  override func configuration(shielding application: Application) -> ShieldConfiguration {
    postShieldShownNotification(placeholders: [
      "applicationOrDomainDisplayName": application.localizedDisplayName
    ])
    return buildShield(/* title, subtitle, colors from the App Group */)
  }
  // ...same hook in the category and web-domain overloads
}

Ini memang akal-akalan, dan bergantung pada pengguna yang mengizinkan notifikasi, tapi cuma ini jalan masuk ke aplikasi yang berhasil secara konsisten buatku.

10. Mempertahankan extension kustom saat prebuild

library Kalau kamu pakai Expo, expo prebuild menjalankan config plugin react-native-device-activity, yang secara default menyalin source extension bawaan library ke ./targets setiap kali dijalankan. ShieldConfiguration yang sudah aku kustomisasi diam-diam tertimpa. Solusinya cukup satu opsi plugin:

["react-native-device-activity", {
  "appleTeamId": "XXXXXXXXXX",
  "appGroup": "group.com.honorfit.blocker",
  "copyToTargetFolder": false
}]

Harganya: saat meng-upgrade library-nya, kamu harus membandingkan (diff) targets/ dan kode Swift bersamanya dengan punyamu secara manual, lalu menerapkan ulang perubahanmu.

11. Xcode 26, Simulator, dan pengujian

12. Lalu di Android

Sebagai perbandingan, Android tidak punya padanan resmi, jadi kamu membangunnya sendiri. Pemblokir HonorFit di Android adalah foreground service (tipe specialUse, karena memblokir aplikasi tidak cocok dengan tipe mana pun yang ada di daftar) yang mengecek UsageStatsManager sekali per detik selama layar menyala untuk tahu aplikasi mana yang sedang di depan. Saat aplikasi yang diblokir terbuka dan saldonya nol, service ini menggambar layar kunci TYPE_APPLICATION_OVERLAY di atasnya (“Tampilkan di atas aplikasi lain”). Sebuah boot receiver dan alarm watchdog menyalakan ulang service setelah reboot, update aplikasi, dan pengelola baterai OEM yang agresif.

Untung-ruginya berkebalikan dengan iOS. Kamu melihat nama package yang asli, jadi privasi adalah janji yang kamu tepati sendiri, bukan yang dipaksakan oleh OS. Aplikasi yang diblokir bisa kelihatan selama paling lama satu interval pengecekan, dan aplikasinya tetap jalan di bawah overlay. Selain itu, kamu harus menulis deklarasi di Play Console untuk tipe foreground service, izin overlay, dan visibilitas package. Aku sengaja menghindari AccessibilityService, yang mendapat review paling ketat dari semuanya.

13. Daftar periksa

GejalaKemungkinan penyebabSolusi
Provisioning gagal untuk satu targetEntitlement atau App Group belum ada di bundle ID ituAjukan Family Controls (Distribution) dan pasang App Group untuk setiap bundle ID
Event threshold tidak pernah terpicu sama sekaliintervalStart = sekarang, jadi monitor dijadwalkan untuk besokMundurkan waktu mulai; jaga interval ≥ 15 menit
Monitor untuk besok malah jalan hari iniJadwal mencakup waktu saat iniJadwal [00:00, T) yang sudah kedaluwarsa hari ini, atau repeats: true dengan aksi yang dijaga
Aplikasi sekilas terblokir saat aplikasimu dibukastopMonitoring memicu intervalDidEndHapus aksi sebelum menghentikan monitor; jaga dengan waktu “jangan sebelum”
“Waktu habis” setelah HP lama didiamkanThreshold hantu iOS 26Pengaman jam dinding ditambah filter lonjakan
Threshold berikutnya tidak pernah terpicuHilang/digabung saat pemakaian putus-sambungPasang ulang monitor baru setiap kali masuk background
Tombol shield tidak mau membuka aplikasiTidak ada API resmiKirim notifikasi dari ShieldConfiguration saat shield ditampilkan
Event yang terpicu tidak cocok (RN)Garis bawah di nama eventPakai used-7, bukan used_7
Picker terbuka kosong (RN)Prop includeEntireCategory tidak adaOper prop-nya, dengan nilai apa saja

14. Jawaban singkat

Apakah aku butuh entitlement Family Controls untuk setiap extension?

Ya. Entitlement ini diberikan per bundle ID. Aplikasi ditambah extension DeviceActivityMonitor, ShieldConfiguration, dan ShieldAction berarti empat persetujuan, dan setiap App ID juga butuh App Group yang sama.

Kenapa eventDidReachThreshold di DeviceActivity tidak terpicu?

Buatku biasanya karena jadwal tidak berulang yang mulai di “sekarang”, yang akhirnya dijadwalkan untuk besok. Mundurkan intervalStart. Di iOS 26, dengan pemakaian yang keluar-masuk, threshold di bawah satu menit yang datang belakangan juga hilang; memasang ulang monitor baru membantu.

Bisakah extension ShieldAction atau ShieldConfiguration membuka aplikasi induknya?

Tidak dengan API resmi, dan trik NSExtensionContext tidak berpengaruh apa-apa di iOS versi sekarang saat aku uji. Yang aku pakai di aplikasi adalah notifikasi lokal yang dikirim dari ShieldConfiguration saat shield ditampilkan.

Bisakah aku menguji Screen Time API di iOS Simulator?

Sebagian. Prompt dan pemilih aplikasinya muncul di simulator iOS 26, tapi otorisasinya tidak selesai, pemilihnya tidak punya apa-apa untuk ditampilkan, dan extension monitor tidak pernah jalan. Pakai perangkat sungguhan.

15. Kenapa aku butuh semua ini

Aku membuat HonorFit, aplikasi blokir tempat kamu mendapatkan screen time dengan push up. Aplikasinya pakai sistem kejujuran: kamu mengetik jumlah rep-mu sendiri, tanpa kamera, tanpa AI, dan tanpa koneksi jaringan. Semua yang ada di atas adalah apa yang dibutuhkan supaya “menit yang kamu dapat cuma berkurang selama aplikasi yang diblokir ada di layar” bisa jalan di iPhone. Kalau mau lihat aplikasinya jalan, ada di App Store dan Google Play. Kalau kamu ke sini karena mau aplikasi seperti itu, bukan mau membuatnya, aku sudah menulis cara kerja aplikasi blokir push up dan ulasan jujur soal aplikasi-aplikasi yang ada, termasuk para pesaing. Kalau kamu menemukan perilaku Screen Time yang tidak ada di sini, atau menemukan bahwa sesuatu di atas sudah salah di iOS yang lebih baru, aku mau sekali mendengarnya.

Baca juga