minulo は、寝る時間になると公式の Instagram・YouTube・TikTok などのアプリを毎晩自動で止め、リールとショートは消したまま DM は残す iPhone アプリです。リールとショートを消すのは無料で、公式アプリを止めるのは1回2時間まで無料、毎晩決めた時間に自動で始まるスリープモードは minulo Pro の機能です。この記事では、その「毎晩自動で止める」をスクリーンタイムの API(DeviceActivity と ManagedSettings)でどう作ったかと、作ってみてはまったところを書きます。(この記事は minulo の開発者が書いています)
API のくわしい挙動は Apple の開発者向けドキュメントで確かめてください。ここに書くのは minulo で実際に使っている形と、実機で起きたことです。拡張の雛形は Robert Herber さんの react-native-device-activity を元にしています。
全体の形
- アプリ本体(React Native)が、寝る時間と起きる時間から DeviceActivitySchedule を作り、DeviceActivityCenter で見張りを始める。
- 時間になると、OS が DeviceActivityMonitor の拡張を
intervalDidStartで呼ぶ。拡張が ManagedSettings でシールド(ブロック画面)をかける。 - 起きる時間に
intervalDidEndが呼ばれ、拡張がシールドを外す。 - アプリが閉じていても、拡張は OS から呼ばれる。
拡張は、その場で何をするかを自分では決めません。アプリ本体が「この見張りの始まりにはこれをする」という動作を、App Group の UserDefaults に actions_for_<見張りの名前>_<合図> の鍵で書いておき、拡張はそれを読んで実行するだけにしています。拡張はメモリが小さく、合図ごとに短い時間しか動けないので、重い判断を持ち込まないためです。
見張りの名前を決めておく
見張りには名前(DeviceActivityName)が要ります。minulo では、スリープモードは1つの決まった名前、ふつうの予定は fm.sch.<予定の id>、曜日ごとに分けた予定は fm.sch.<予定の id>.<曜日> にしています。拡張は名前から予定の id を取り出して、その予定についての判断(次の節の「今夜だけ解除」)をします。
func scheduleId(_ activityName: String) -> String? {
if activityName == SLEEP_ACTIVITY { return "bed" }
guard activityName.hasPrefix("fm.sch.") else { return nil }
let rest = String(activityName.dropFirst("fm.sch.".count))
if let dot = rest.lastIndex(of: "."), Int(rest[rest.index(after: dot)...]) != nil {
return String(rest[..<dot]) // 末尾の曜日を落とす
}
return rest
}
今夜だけ解除する
「今夜だけ解除する」を押したとき、見張りそのものを止めてしまうと、翌日からの毎晩の予定も消えます。そこで見張りは残したまま、「この予定のこの回は終えた」という印だけを書くことにしました。
- アプリ本体が、予定の id と「その回が明ける時刻」を JSON にして App Group に書く。
- 拡張は
intervalDidStartのたびにその印を読み、終えた回なら何もしない。 - 明ける時刻を過ぎた印は読むときに捨てる。
override func intervalDidStart(for activity: DeviceActivityName) {
super.intervalDidStart(for: activity)
var actions = loadActions(activity.rawValue, "intervalDidStart")
if let id = scheduleId(activity.rawValue), skips()[id] != nil {
actions = [] // 今夜は解除済み
}
run(actions)
}
はまったところ1:時間帯の途中で張り直すと、その場で「始まり」が呼ばれる
予定を編集したときや、何かの理由で見張りを張り直したとき、その時間帯の途中だと、OS はその場で intervalDidStart を呼びます。minulo では、緊急の解除(非常口)で見張りが止まった夜に見張りを張り直したところ、OS がその場で始まりを呼び、解除したはずの夜にもう一度全部止まる、ということが起きました。
上の「今夜だけ解除」の印を、張り直しのときの intervalDidStart でも同じように見るようにして直しました。スリープモードの見張りも、予定と同じ印(bed)で見るようにしています。
はまったところ2:終わりの合図で、ほかの見張りのシールドまで外れる
ManagedSettings のシールドは、見張りごとに別々の箱があるわけではありません。夜の見張りの intervalDidEnd でシールドを外すと、同じ時間に別の理由(手で始めた2時間のブロックなど)でかけていたシールドまで外れることがあります。
そこで、終わりの合図で外したあとに「まだ必要な人がいれば掛け直す」動作を一緒に登録し、その動作に「誰のための掛け直しか」(予定の id や、手のブロックが終わる時刻)を持たせました。拡張は、全員が終えた回なら掛け直さず、まだ終わっていない人がいれば掛け直します。
はまったところ3:時刻はその地域の時計で決まる
DeviceActivitySchedule の時刻は、端末の地域の時計で動きます。旅行で地域が変わると、アプリ本体が書いた「この時間帯は予定の中」という印と、拡張が見ている今の時刻がずれます。minulo では、印に書いたときの地域の名前を入れておき、名前が違えば拡張は迷ったほうを選ばず何もしない(次にアプリを開いたときに本体が直す)ようにしました。夏時間の切り替え日に「地域が変わった」と誤って読んだことがあったので、比べるのは UTC との差ではなく地域の名前にしています。
できないこと
- 止めている間、公式アプリの通知は届きません。シールドは iOS の仕組みなので、アプリ側では変えられません。
- シールドは公式アプリそのものを止めるので、「リールだけ止めて DM は残す」は公式アプリの中ではできません。minulo では、DM は minulo の中でウェブ版を開いて使ってもらう形にしています。
まとめ
- 毎晩の自動のブロックは、DeviceActivity の見張りと、拡張が読む「動作の表」で作れる。
- 「今夜だけ解除」は見張りを止めずに、終えた回の印で作る。
- 張り直しの瞬間の
intervalDidStart、シールドの共有、地域の時計の3つは、実機で必ず一度はまる。
寝る前のリールとショートを止める設定全体(休止時間やスクリーンタイムのスケジュールとの違い)は、次のページにまとめています。