mikiyan1978’s 脱獄情報日記

AndroidやiOSに関する情報を発信します

iOSの「電源を操る」Tweakを作る ── backboardd KillとUserspace Rebootを無害化する

はじめに

ジェイルブレイク環境では、音楽を再生しながらリスプリング(SpringBoardの再起動)を行う場面が頻繁にあります。Tweakのインストール、設定変更の反映、見た目のリフレッシュ──理由は様々ですが、その裏で「意図しない音声の停止」というユーザー体験の悪化が長年放置されてきました。

本記事では、実機(iPhone 8 Plus, iOS 16.7.16, rootful環境)での検証を通じて発見した、iOSの電源・リスプリング関連の内部挙動と、それらを安全に制御する具体的な実装方法を紹介します。

発端:なぜ「Killしただけ」で音楽が止まるのか

素朴な仮説は「killall backboarddのようなコマンドを実行すると、backboardd というプロセスが再起動され、それに伴って他のアプリの表示が一瞬止まるだけだろう」というものでした。ところが実機で検証すると、状況はもっと深刻でした。

発見1:backboardd は「表示が切れる」のではなく「プロセスごと強制終了される」

Fridaを使い、Music.appとSpringBoardの双方に同時にトレースを仕掛けた状態でkillall backboarddを実行したところ、次のことが分かりました。

  • Music.appはAVAudioSessionInterruptionなどの通知を一切受け取らないまま、プロセスごと強制終了される
  • SpringBoard自身も同時に再起動される(PIDが変わる)
  • つまりkillall backboarddは、backboardd単体の再起動ではなく、実質的にフルリスプリングと同等のカスケードを引き起こす

さらに踏み込んで分かったのは、backboardd は外部からの干渉そのものを許容しないという事実です。Fridaでプロセスに接続を試みる(何も変更を加えない、ただの観測目的の接続)だけで、backboardd自体がクラッシュしました。これは偶然の実験結果として得られたものですが、「backboardd Killは原理的に対処不能」という結論を裏付ける強力な証拠になりました。

対策:kill(2)を「握りつぶして」安全な代替に置き換える

ここで採用したアプローチは、backboardd宛のシグナルをそもそも配送しないというものです。

%hookf(int, kill, pid_t pid, int sig)
{
    if (pid > 0 && RKAIsAuthorized()) {
        char name[64] = {0};
        proc_name_fn pnFn = RKAProcName();
        int len = pnFn ? pnFn(pid, name, sizeof(name)) : 0;
        if (len > 0 && strcmp(name, "backboardd") == 0) {
            RKAMarkBackboarddKilled();
            if (RKARedirectBackboarddKillSignalEnabled()) {
                CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
                                                      kGentleRespringRequestName, NULL, NULL, true);
                return 0; // %origを一切呼ばない = backboarddには何も届かない
            }
        }
        // ...
    }
    return %orig(pid, sig);
}

kill()はlibSystemの正規の公開シンボルであるため、Logosの%hookfで素直にフックできます。ポイントは「シグナルの種類をダウングレードする」のではなく、「そもそも配送しない」ことです。以前試した「SIGKILLをSIGTERMに格下げする」方式では、backboardd はSIGTERMを受け取っても同じようにクラッシュしました。これは、backboardd の脆弱性がシグナルの種類に依存しない、より根本的な性質であることを示しています。

その代わりに、SpringBoard自身が持つ正規のAPI(SBSRelaunchAction経由のFBSSystemService sendActions:)を使って、安全なジェントルリスプリングを要求します。呼び出し元の意図(「UIをリフレッシュしたい」)は尊重しつつ、実際の実装だけを安全なものに差し替える、というのがこの設計の核心です。

発見2:呼び出し元によって注入が届かない場合がある

このkill()フックは、コマンドラインからkillall backboarddを実行した場合や、Zeus(別のリスプリングツール)から実行した場合には正しく機能しました。しかし、サンドボックス化されたアプリ(Preferences.app)内からNSTaskでkillallを起動した場合、Tweakの注入自体が一切届かないという制約が判明しました。

// 修正前:注入が届かないサンドボックス経由のkillall
- (void)respring {
    NSTask *task = [[NSTask alloc] init];
    [task setLaunchPath:@"/usr/bin/killall"];
    [task setArguments:@[@"backboardd"]];
    [task launch];
}

これはMobileSubstrate/ElleKit側の注入メカニズムの限界であり、kill()フックのロジックをいくら改良しても解決できません。唯一の解決策は、そもそもbackboarddに触れないように呼び出し元自体を書き換えることでした。

// 修正後:安全なDarwin通知を直接送る
- (void)respring {
    CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
                                          CFSTR("com.mikiyan1978.playonrespring.gentleRespring"),
                                          NULL, NULL, true);
}

Darwin通知の送信にはエンティトルメントが不要なため、サンドボックスの有無に関わらずどのプロセスからでも安全に呼び出せます。これは、Apple自身が用意した正規の低レベルIPC機構を使うことで、注入ベースの介入が届かない場所でも同じ効果を実現する、という設計パターンです。

発見3:Userspace Rebootの正体は「生のカーネルsyscall」だった

次に取り組んだのは、launchctl reboot userspace(カーネルを再起動せず、ユーザー空間のプロセスツリーだけを再構築する高速な再起動)でした。

最初の仮説は、シンボルテーブルに見つかったlaunch_userspace_reboot_with_purposeという専用関数がこの機能を担っているというものでした。しかし、launchctlバイナリを実機で実行せず、Macにダウンロードしてオフラインで逆アセンブルした結果、これは完全に誤りだと判明しました。

_reboot_cmd:
  ; "system" / "halt" / "userspace" / "obliterate" の文字列比較
  ; それぞれ異なるフラグビットをx0にセットして...
  bl  _reboot3   ; ← 全パターンが最終的にここに収束する

launch_userspace_reboot_with_purposeは、実は無関係なenter-rem(Restricted Execution Mode)コマンド専用の関数でした。reboot userspaceを含むすべてのlaunchctl rebootサブコマンドは、種類ごとに異なるフラグビットを立てて、生のカーネルsyscallラッパーであるreboot3(uint64_t flags)を呼んでいるだけだったのです。

サブコマンド フラグビット
system 0x8000000000000000
userspace 0x2000000000000000
halt / obliterate 複合ビット

この発見は、実機を一切操作せずotool -tVによるバイナリ解析だけで得られたものです。ライブ環境での試行錯誤には常に「本当に再起動してしまう」リスクが伴うため、まず安全なオフライン解析で仮説を固めてから実機検証に移る、という手順を徹底しました。

reboot3のフック:%hookfが使えない特殊なケース

reboot3はlibSystemの正規のエクスポートシンボルとして存在が確認できたものの(libSystem.B.tbdに記載あり)、実際に%hookfでコンパイルを試みると、リンカが解決を拒否しました。

ld: Undefined symbols:
  reboot3(unsigned long long), referenced from:
      _logosLocalInit() in Tweak.xm.o

これは、シンボルテーブル上の存在と、静的リンカによる解決可否が必ずしも一致しないという、興味深いケースでした。解決策は、dlsymによる実行時解決と、MSHookFunctionによる手動フックの組み合わせです。

typedef int (*RKAReboot3Fn)(uint64_t flags);

static RKAReboot3Fn RKAReboot3Symbol(void) {
    static RKAReboot3Fn fn;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        fn = (RKAReboot3Fn)dlsym(RTLD_DEFAULT, "reboot3");
    });
    return fn;
}

static int (*RKAOrigReboot3)(uint64_t flags);
static const uint64_t kRKAUserspaceRebootFlag = 0x2000000000000000ULL;

static int RKAReplacementReboot3(uint64_t flags) {
    BOOL isUserspaceReboot = (flags & kRKAUserspaceRebootFlag) != 0;
    if (isUserspaceReboot && RKARedirectUserspaceRebootEnabled()) {
        CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
                                              kGentleRespringRequestName, NULL, NULL, true);
        return 0; // 実際のsyscallは一切発行されない
    }
    return RKAOrigReboot3(flags);
}

static void RKAInstallReboot3Observer(void) {
    RKAReboot3Fn realReboot3 = RKAReboot3Symbol();
    if (realReboot3) {
        MSHookFunction((void *)realReboot3, (void *)RKAReplacementReboot3, (void **)&RKAOrigReboot3);
    }
}

実機での検証結果は明確でした。フックを有効にした状態でlaunchctl reboot userspaceを実行したところ、SSH接続は切断されず、uptimeも連続したまま、backboardd のPIDも一切変わりませんでした。ログには次の一行だけが記録されます。

reboot3(flags=0x2000000000000000) called by launchctl -- swallowed (userspace reboot redirect enabled), requesting gentle SpringBoard respring instead

本来のuserspace rebootは一度も発生せず、代わりに安全なSpringBoard単体のリスプリングに完全に置き換わりました。

設計思想:「握りつぶすべきもの」と「握りつぶすべきでないもの」

ここで重要なのは、backboardd Killとuserspace rebootでは、デフォルトの挙動をあえて変えている点です。

  • backboardd Kill → デフォルトON(常に握りつぶす) backboardd の死は、呼び出し元の意図(大抵は「UIをリフレッシュしたい」)と実際の結果(アプリの強制終了)が常に乖離しています。巻き添え被害でしかないため、無条件に無害化して問題ありません。

  • Userspace Reboot → デフォルトOFF(オプトイン) 一方でuserspace rebootは、呼び出し元が「本当に全プロセスを再起動したい」という明確な意図を持って呼んでいるケースがほとんどです。特定のTweakをインストールした直後など、実際にuserspace rebootが必要な場面も存在します。ここを無条件に握りつぶしてしまうと、今度は「意図した動作が起きない」という別の不具合を生んでしまいます。

同じ「危険な電源操作を無害化する」という技術でも、その操作が持つ本来の意図を尊重するかどうかで、デフォルトの挙動を使い分けることが設計上重要でした。

検証プロセスにおける失敗と教訓

この調査の過程では、意図しない実機再起動を2度経験しました。

  1. launchctl rebootをサブコマンドなしで実行しただけで、実際に再起動が発生
  2. Frida の spawn+hook(プロセスをサスペンド状態で起動し、実行前にフックを仕込む手法)で、フックの設置に失敗した際、Fridaが自動的に元のプロセスを再開してしまい、本来のコマンドがそのまま実行された

この経験から得られた教訓は、「危険な可能性のあるコマンドは、まず絶対に実行を伴わない方法で調査する」という原則です。具体的には次の順序を徹底しました。

  1. バイナリをローカルにダウンロードし、otool/nm/stringsでオフライン解析する
  2. 動的解決(dlsym)の可否を、呼び出さずに確認だけするスクリプトで検証する
  3. 実機での動作確認が避けられない場合は、まず「観測のみで一切ブロックしない」バージョンで安全性を確認してから、実際の介入ロジックを追加する

この段階的なアプローチにより、最終的な実装(backboardd Killの完全な握りつぶしと、userspace rebootのオプトイン制御)を、事故なく実機で確認するところまでたどり着くことができました。

まとめ

  • backboardd のKillは、シグナルの種類に関わらずプロセス全体をクラッシュさせ、フルリスプリング相当のカスケードを引き起こす
  • サンドボックス化されたアプリからの注入は届かないため、呼び出し元自体を安全なAPI呼び出しに書き換える必要がある場合がある
  • Userspace Rebootは専用APIではなく、生のカーネルsyscall reboot3()をフラグビットで種別分岐しているだけであり、kill()と同様にフック可能
  • ただしreboot3は静的リンクでは解決できず、dlsym + MSHookFunctionによる手動フックが必要
  • 「巻き添え被害を無害化する」機能と「意図的な操作を無害化する」機能では、デフォルトの挙動を分けるべきである
  • 危険な操作の調査は、まず実行を伴わない静的解析から始めるべきである

【脱獄Tweak】MacDockをリリースしました — iOSでMac風フローティングウィンドウ

こんにちは、mikiyan1978です。

iOS脱獄環境向けTweak「MacDock」を開発・公開しました。


MacDockとは?

任意のアプリをフローティングウィンドウとして表示し、複数アプリを同時に操作できるTweakです。

macOSのウィンドウ管理をiOSで再現することを目標に開発しました。Multiplexerにインスパイアされており、ウィンドウの移動・リサイズ・スナップ・位置記憶など、多彩な機能を実装しています。


インストール方法

Sileoからインストール

  1. Sileoを開く
  2. ソースタブ → 右上編集 → +
  3. 以下のURLを入力して追加:
https://mikiyan1978.github.io/sileo-repo/
  1. MacDockを検索してインストール

ランディングページ(Sileoに追加ボタンあり):
👉 https://mikiyan1978.github.io/sileo-repo/

GitHubリポジトリ:
👉 https://github.com/mikiyan1978/sileo-repo


動作確認済み環境

⚠️ 現在以下の環境でのみテスト済みです

項目 内容
デバイス iPhone 14 Pro
iOS 16.2
脱獄ツール Dopamine (rootless)
アーキテクチャ iphoneos-arm64

他の環境での動作状況は未確認です。
動作した・しなかった等、ぜひコメントまたはXへのリプライで教えていただけると助かります!

👉 X(Twitter): @mikiyan1978


主な機能

⧉ ウィンドウ化

アプリ内に表示される⧉ボタンをタップ、またはアプリ選択パネルから任意のアプリをウィンドウ化できます。

🔴🟡🟢🔵 Traffic Lightボタン

macOS風のボタンでウィンドウを操作します。

  • 🔴 赤: ウィンドウを閉じる(アプリも終了)
  • 🟡 黄: 0.6倍に縮小/復元トグル
  • 🔵 青: ウィンドウを解除してフルスクリーンで起動

👆 ヘッダーバーのジェスチャー

ジェスチャー 動作
シングルタップ ボタン表示/非表示
ダブルタップ 0.6倍縮小/復元
長押し(0.7秒) ウィンドウを閉じる
ドラッグ ウィンドウを移動

↔️ リサイズ

  • ピンチジェスチャー(2本指)でサイズ変更
  • 右下ハンドルをドラッグでサイズ変更
  • 最小サイズ: 120pt

📌 画面端スナップ

端から70pt以内で放すと自動で吸い付きます。上下左右に対応。Dynamic Island考慮済み。

💾 ウィンドウ位置の記憶

閉じた時の位置とサイズをアプリごとに記憶。次回起動時に自動復元。

📋 アプリ選択パネル

画面右端のつまみを左スワイプで開きます。

  • 上部: ウィンドウ化中のアプリアイコン(横スクロール・タップで前面に)
  • 下部: インストール済みアプリ一覧(サービス系は自動除外)

🪟 マルチウィンドウ

複数のアプリを同時にウィンドウ化して並べて使えます。


設定

設定アプリ → MacDock から各種設定が変更できます。

設定項目 説明
MacDock を有効にする オン/オフ切り替え(respring不要・即時反映)
画面端に自動スナップ 端に吸い付くオン/オフ
位置・サイズを記憶する ウィンドウ位置の記憶オン/オフ
記憶した位置をリセット 全アプリの記憶を削除
ドラッグ中の透明度 0.3〜1.0で調整

設定UIは宇宙/SF風のオリジナルデザインで、星空アニメーション背景を採用しています。


技術的な話

Theos + Objective-C(Logos)で開発しました。SpringBoardをフックして_UISceneLayerHostContainerViewを活用し、アプリのレイヤーをフローティングウィンドウに組み込む仕組みです。

設定の変更はNSDistributedNotificationCenterを使ってSpringBoardにリアルタイムで通知されるため、respring不要で即座に反映されます。


⚠️ 注意事項・既知の問題

  • NewTermなど一部アプリはSPUISecureWindowのみを使用するため、ウィンドウ化できません
  • ホーム画面に⧉ボタンが表示される場合はuserrebootを実行してください(killall SpringBoardでは解消されない場合があります)
  • 本Tweakは脱獄環境専用です。使用による不具合・データ損失等について開発者は責任を負いません

動作報告をお待ちしています!

現在、Dopamine + iOS 16.2 + iPhone 14 Pro の環境でのみ動作確認しています。

他の環境(iOS 15・16・17、A14/A15/A16チップ、他デバイスなど)での動作状況を知りたいです。

✅ 動作した
❌ 動作しなかった
⚠️ 部分的に動作した

などの情報を以下にお寄せください!

  • 📝 このブログのコメント欄
  • 🐦 X(Twitter)でリプライ: @mikiyan1978

バグ報告・機能要望もお気軽にどうぞ。


リンクまとめ

URL
🏠 ランディングページ https://mikiyan1978.github.io/sileo-repo/
📦 Sileoリポジトリ https://mikiyan1978.github.io/sileo-repo/
💻 GitHub https://github.com/mikiyan1978/sileo-repo
📖 開発ブログ http://mikiyan1978.hatenablog.com/

by mikiyan1978

【yt-dlp】「動画情報の取得に失敗しました」が全動画で発生。PC無しのTermux環境だけでログを読み解いて原因を突き止めた記録

私はPOCO F8 ProのTermux環境だけを使い、PCに頼らずAndroidアプリを開発しています。この縛りは単なる制約プレイではなく、「PCが使えない環境の人が同じ壁にぶつかったときに、そのまま読んで解決できる記録を残しておきたい」という考えから、あえて選んでいるものです。今回は自作のYouTubeダウンローダーアプリで発生した「動画情報の取得に失敗しました」というエラーが全動画で再現するようになった件について、Termux上のログだけを頼りに原因を特定し、解決するまでの過程を詳しく記録します。

発生した症状

自作の動画ダウンロードアプリ(内部でyt-dlpを呼び出す構成です)にYouTubeのURLを渡すと、どのURLでも例外なく以下のメッセージが表示されるようになりました。

動画情報の取得に失敗しました。通常の動画取得を試します。

このメッセージ自体はアプリ側が用意している汎用的なフォールバック文言で、実際に何が原因で失敗しているのかまでは教えてくれません。表面的な症状だけを見ると「特定の動画が非対応になったのかもしれない」「著作権や地域制限に引っかかっているのかもしれない」といった仮説も浮かびますが、今回は試したすべての動画で例外なく再現していました。これは個別の動画側の事情ではなく、アプリまたはyt-dlpの環境・設定側に共通の原因があることを強く示唆しています。トラブルシューティングにおいて、この「再現範囲の広さ」は原因の当たりをつけるうえで非常に重要な手がかりになります。

アプリ経由ではなく、生のyt-dlpで直接再現させる

アプリのUI上のエラーメッセージだけでは詳細な失敗理由がまったく分からないため、まずはTermux上で直接yt-dlpコマンドを実行し、verbose(詳細)ログを取得することにしました。アプリはあくまでyt-dlpのラッパーに過ぎないので、根本原因を切り分けるには、ラッパーを介さずコマンドライン本体を直接叩くのが最短ルートです。

yt-dlp -v "https://youtube.com/watch?v=XXXXXXXXXXX" 2>&1 | tail -50

-v オプションでverboseログを有効にし、標準エラー出力も含めてすべて拾い、末尾50行だけを表示させています。出力されたログの中に、見落としてはいけない一行を見つけました。

WARNING: [youtube] Skipping unsupported client "ios_creator"

さらにその後のログを追っていくと、以下のような失敗の連鎖が起きていることが分かりました。

  1. 指定していた ios_creator というクライアントがスキップされる
  2. フォールバック先として tv_downgraded クライアントでプレイヤーAPIを取得しようとするが、再生可否ステータスが UNPLAYABLE(再生不可)と判定される
  3. さらに web_safari クライアントにもフォールバックするが、「YouTubeがこのクライアントに対してSABRストリーミングを強制しているため、URLを持たないフォーマットがいくつかスキップされた」という趣旨の警告が出る
  4. 最終的に有効なフォーマットが1つも確保できず、以下のエラーで処理が終了する
ERROR: [youtube] The page needs to be reloaded.

「ページの再読み込みが必要です」という、一見するとブラウザ側の問題のように見えるエラーメッセージですが、実態はyt-dlp側が有効な再生用フォーマットを1つも取得できなかった際に出る定型のエラーメッセージです。

原因の切り分けと背景の理解

ログの流れを整理すると、根本原因は次のように説明できます。

Termux上の ~/.config/yt-dlp/config というyt-dlpのユーザー設定ファイルに、以前から以下のような行が記述されていました。

--extractor-args "youtube:player_client=ios_creator"

これは「YouTube動画の情報取得やフォーマット取得の際に、ios_creator というクライアント(iOSアプリの内部APIを模したクライアント種別)を優先的に使う」という指定です。おそらく過去に、他のクライアントではうまく取得できない動画があった際に、この指定を追加して回避していたのだと思われます。

しかし今回問題が発生した時点では、YouTube側のAPI仕様変更、あるいはyt-dlp側のextractor実装更新により、ios_creator というクライアント名自体がyt-dlpでサポート対象外になっていました。指定したクライアントが使えないため、yt-dlpは内部的に用意されている別クライアントへの自動フォールバック処理に入ります。ところがフォールバック先の tv_downgraded や web_safari も、YouTube側の対策(SABRストリーミングの強制、PO Token要求の強化など)によって、いずれも有効な再生用URLを返してくれない状態になっていました。結果として、どのクライアントを試しても最終的にフォーマットが1つも確保できず、エラーで終了していた、というのが今回の全体像です。

もう一つ見逃せない要因として、Termux環境のyt-dlp自体のバージョンが 2026.07.04 と、1ヶ月以上前のものだったことも挙げられます。yt-dlpはYouTube側の仕様変更に追従するため非常に高頻度でリリースが行われるツールです。バージョンが古いままだと、YouTube側の変更に対応した修正が反映されておらず、今回のような問題がより発生しやすくなります。

対処方法

対処は大きく分けて2段階で行いました。

1. yt-dlp本体を最新版に更新する

まず素直に yt-dlp -U を試しましたが、以下のエラーで拒否されました。

ERROR: You installed yt-dlp from a manual build or with a package manager; Use that to update

「手動ビルドやパッケージマネージャー経由でインストールされているため、そちらの手段で更新してください」という趣旨のメッセージです。そこで、どの方法でインストールされているかを確認しました。

pip show yt-dlp | head -5

Location の項目を見ると .../site-packages 配下であることが分かり、pip経由でインストールされていることが確認できました。これを踏まえ、以下のコマンドで更新を行いました。

pip install --upgrade yt-dlp --break-system-packages

Termux環境のPythonはシステム管理下のパッケージとして扱われるため、--break-system-packages オプションを付けないとインストールが拒否される点に注意が必要です。この更新により、バージョンが 2026.07.04 から 2026.08.19 へと上がりました。

2. 設定ファイルから廃止されたクライアント指定を削除する

続けて、原因となっていた設定ファイルの該当行を削除しました。

sed -i '/extractor-args/d' ~/.config/yt-dlp/config

sed の /extractor-args/d は、「extractor-args という文字列を含む行を削除する」という意味の削除コマンドです。これにより、設定ファイルには --cookies の指定のみが残り、クライアントの強制指定は撤去されました。今後はyt-dlpが持つ自動選択ロジックに、どのクライアントを使うかの判断を委ねる形になります。

修正後の検証

設定変更後、再びverboseログ付きでyt-dlpを実行したところ、今度は web_embedded クライアントで正常にプレイヤー情報が取得でき、有効なフォーマットも問題なく確保できました。

[youtube] VPMO8yjOrXU: Downloading web embedded player API JSON
[info] VPMO8yjOrXU: Downloading 1 format(s): 401+251
[download]  58.4% of  314.82MiB at  ...

ダウンロード自体も最後まで正常に進行することを確認できました。続けて自作アプリ側からも同じURLでテストし、サムネイル画像・動画タイトル・投稿日や再生数などの詳細情報・概要欄の取得、そしてダウンロード進捗の表示まで、すべて正常に動作することを確認しました。アプリ側のエラーの根本原因が、Termux側のyt-dlp設定にあったことがこれで裏付けられました。

今回の教訓

  • --extractor-args を使ってクライアントを固定指定する運用は、YouTube側の仕様変更やyt-dlp側のサポート状況の変化に対して脆弱です。固定指定を行う際は、「なぜその指定を入れたのか」という理由を必ずコメントやメモとして残しておくべきだと痛感しました。理由が分からなくなると、今回のように原因不明のトラブルとして再浮上してしまいます。
  • 症状が全動画・全URLで再現する場合は、個別の動画側の事情ではなく、環境や設定など共通の要因を先に疑うのが定石です。
  • verboseログに出力される WARNING 行は、ERROR に比べて見落とされがちですが、今回のように根本原因がピンポイントで示されていることがあります。エラーメッセージだけでなく、その手前に出ている警告メッセージまで丁寧に読む価値があります。
  • yt-dlpのようにリリース頻度が高く、対象サービス側(今回で言えばYouTube)の仕様変更に追従し続ける必要があるツールは、バージョンを固定したまま長期間放置すると、ある日突然すべての機能が動かなくなるリスクを抱えます。pip install --upgrade yt-dlp --break-system-packages のような更新コマンドを定期的に実行する運用を習慣化しておく必要性を改めて感じました。

PCが手元になくても、Termuxとverboseログの読み解きだけで、ここまで根本原因を突き止めることは十分に可能です。同じように「全動画で失敗する」という現象に遭遇した方の助けになれば幸いです。

PCを持たなくても、ホーム画面の「動くアイコン」は作れます ― スマホ単体でまばたきウィジェットを実装するまでの全記録

飛行機に乗れば数時間で着く場所に、あえて陸路と海路だけで向かう。そんな遠回りを自分に課しながら、今日もPOCO F8 Pro一台とTermuxだけでAndroidアプリを作っています。

今回のお題は「ホーム画面に置ける、動くアイコン」です。ストレージクリーナー系アプリによくある、パーセンテージがリアルタイムで書き換わるあの表示を見て、「これウチの自作猫アプリでもできないか」と思ったのがきっかけでした。

結論から言うと、実現できました。ただし最短ルートではまったくなく、見た目の小手先修正では解決しない、Android OSの内部構造に踏み込む必要がある壁が2つありました。この記事では、その壁の正体と越え方を、PC不使用・スマホ単体という制約込みで、A(発想)からZ(完成)まで順を追って残しておきます。

空路を選べない理由は人それぞれでよいと思います。経済的な事情かもしれませんし、環境の問題かもしれませんし、単に「今PCが手元にない」というだけかもしれません。理由がなんであれ、この記事が「今いる場所から、望む場所まで」の地図の一部になれば幸いです。


A. 「動くアイコン」の正体を見極める

まず最初につまずいたのは、「動くアイコン」という言葉が指すものが一つではない、という点でした。

ホーム画面をよく観察すると、あの手のアイコンには実は2種類あります。

  1. アプリ本体のランチャーアイコン自体が変化するもの(ごく一部の特殊な仕組みでしか実現できません)
  2. 1コマだけのミニチュアなホームウィジェットが、見た目上「アイコンのように」置かれているもの

長押しした時に出てくるメニューを見れば区別がつきます。通常のアプリなら「アンインストール」が出るところ、これは「削除」しか出ません。つまり正体はAppWidgetProvider(ホームウィジェット)でした。「アイコンが動く」のではなく、「アイコンサイズのウィジェットが動いている」というのが技術的に正しい理解になります。

ここを最初に見誤ると、的外れな実装(アプリアイコン自体をアニメーションさせようとする等)に時間を溶かすことになるため、最初の切り分けは重要でした。

B. Androidで「動き」を作る3つの選択肢

ウィジェットだと分かったところで、次に「どう動かすか」の設計に入ります。候補は3つありました。

  • AnimationDrawable:複数の画像をコマ送りする、Androidの標準的なアニメーション機構です。ただし通常のViewでしかstart()できません。
  • ライブ壁紙:ホーム画面全体を書き換える大掛かりな仕組みです。1アイコン分の演出には過剰です。
  • RemoteViewsの手動コマ送り:ウィジェットはRemoteViewsという特殊な間接描画の仕組みを使うため、AnimationDrawable.start()が直接使えません。代わりに、一定間隔で画像リソースを手動で差し替え続ける方式です。

3番目が唯一の現実解でした。ウィジェットは「アプリのプロセスが直接Viewを操作する」のではなく、「アプリがシステムに"この見た目にして"と指示を送り、システム側が代理で描画する」という間接構造になっています。この構造上の制約が、後に出てくる最初の壁の伏線になります。

C. まばたきする猫の絵を描く(ベクターで)

今回のベースは自作アプリ「FortressCat」の猫アイコンです。既存のアダプティブアイコンがベクター(XML)で定義されていたため、それを流用し、「目を開いた状態」と「目を閉じた状態」の2枚のベクター画像を用意しました。

目を閉じた状態は、円形の瞳(fillColorの円)を、弧を描く線(strokeColorのパス)に差し替えるだけです。ビットマップ画像と違い、ベクターはただの座標データなので、Termux上でテキストファイルとして手書きできます。PCの画像編集ソフトがなくても、pathDataの数値をいじれば表情が作れるというのは、スマホ単体開発にとって地味に大きい利点でした。

D. AnimationDrawable定義とレイアウトを用意する

2枚の絵ができたら、それを交互に切り替える定義(animation-list)、ウィジェットの見た目を収めるFrameLayoutのレイアウト、そしてウィジェットのサイズや初期レイアウトを指定するメタ情報XML(appwidget-provider)の3点セットを用意します。

ここで最初の小さな事故が起きました。res/xml/というディレクトリがプロジェクトに元々存在しておらず、cat > path/to/fileでファイルを作ろうとしても、存在しない親ディレクトリには書き込めず静かに失敗します。「作成完了」とターミナルに表示されたにもかかわらず、実際にはファイルが1バイトも書かれていませんでした。mkdir -pで先にディレクトリを作ってから書き込む、という当たり前の手順を一つ飛ばしただけで、この後のビルドが延々と「ファイルが見つからない」エラーを吐き続けることになりました。

教訓:新規ファイルを作る際は、先にls -laでディレクトリの存在を確認する癖をつけることをおすすめします。「コマンドがエラーなく終わった」ことは「ファイルが正しく書かれた」ことの証明にはなりません。

E. ウィジェットプロバイダー(Java)を書く

ウィジェットの司令塔となるAppWidgetProviderのサブクラスを実装します。最初のバージョンは、onUpdateが呼ばれたタイミングでHandler.postDelayedを使い、一定間隔でRemoteViewsの画像を差し替え続ける、という素直な実装にしました。

handler.postDelayed(this, eyesOpen ? 2800 : 150);

このコードは一見正しく、実際に設置直後は問題なく動きます。しかしこれが、後述する2つ目の壁の原因になります。

F. ビルド環境特有の壁:aapt2バイナリの罠

Termux(ARM64)上でGradleビルドを行う環境固有の問題として、Gradleが内部でダウンロードしてくるaapt2(リソースコンパイラ)バイナリがx86_64用であり、ARM64のTermuxでは実行できない、というものがあります。

これはAndroidの仕様ではなく、あくまで「PCを使わない」という選択をしたからこそ踏む問題です。すでにfix_gradle_aapt2.shという対策スクリプトを持っていたため実行しましたが、興味深いことに、新しい依存ライブラリを追加してビルドし直すたびに、新しいキャッシュパスにx86_64バイナリが再展開され、対策済みのはずのバイナリがまた無効化されるという現象に遭遇しました。Gradleのキャッシュ構造(transformsディレクトリがハッシュ値ごとに複数生成される)を理解していないと、「一度直したのになぜまた同じエラーが出るのか」でハマるポイントです。

対策としては、ビルドが失敗するたびにfix_gradle_aapt2.shを再実行し、新たに生成されたキャッシュパスも差し替える、という運用でしのいでいます。

G. APKのインストール:サンドボックスの壁

ビルドが通ってAPKができても、それをインストールする段階でもう一つの壁があります。TermuxのアプリプライベートディレクトリはAndroidのサンドボックス機構によって保護されており、Shizuku経由の特権シェル(rish)であってもここには直接アクセスできません。

正しい手順は3段階になります。

  1. 通常ユーザー権限で、公開領域(/storage/emulated/0/Download/)へAPKをコピーする
  2. rish(特権シェル)経由で、公開領域から/data/local/tmp/へさらにコピーする
  3. rish経由でpm installを実行する

「特権を持っているのだから直接インストールできるだろう」という直感が外れる場面で、Androidのファイルアクセス制御が「権限の強さ」と「アクセス可能な場所」を別々の軸で管理していることを、身をもって再確認させられました。

H. 完成、そして最初の物足りなさ

ここまでで、1x1サイズの小さな猫がまばたきするウィジェットが完成しました。ホーム画面に置いて動かしてみると……確かに動いてはいるものの、正直、目を凝らさないと分かりませんでした。1コマの解像度が小さすぎて、まばたきという繊細な変化が埋もれてしまっていたのです。

見た目のインパクト不足に対しては、単純に対症療法で対応しました。

  • まぶたの線を太く、弧をはっきりさせる
  • 口の表情も一緒に変化させる
  • アニメーションの切り替え頻度を上げる

ここまでは想定内の調整でした。しかし、次に踏んだ問題は、見た目の調整では直らない、システムの内部構造に起因するものでした。

I. 第一の壁:サイズが変更を無視し続ける

「もっと大きく、2x2サイズにしてほしい」という要望を受け、appwidget-providerのXML定義でminWidth/minHeight/targetCellWidth/targetCellHeightを書き換えました。ビルドも通り、APKの中身をデコードして確認しても、正しく110.000000dpという値が埋め込まれています。

ところが、何度アプリを再インストールしても、ホーム画面の「ウィジェットを追加」画面には頑固に「1x1」と表示され続けました。アンインストール→再インストールを試しても変わりません。ランチャーアプリ自体のキャッシュをクリアしても(この過程でホーム画面のレイアウトが吹き飛ぶという代償を払いましたが)変わりませんでした。

原因を突き止めるため、dumpsys appwidgetというコマンドで、Androidシステムが実際に記録しているウィジェット登録情報を直接のぞいてみました。すると、そこに記録されていたサイズはこうなっていました。

min=(28161x28161)

意味不明な巨大値です。APK内の定義は正しいのに、システム側の登録情報だけが、明らかに壊れた値のまま固定されています。ここでようやく核心が見えました。これはアプリの不具合ではなく、システムが古いウィジェットプロバイダーの登録情報を「ゾンビ」として握ったまま離さないという現象でした。

Androidのウィジェットシステムは、パッケージ名 + クラス名の組み合わせを一意のキーとしてプロバイダー情報を管理しています。同じ組み合わせのまま何度インストールし直しても、システムから見れば「同じプロバイダーの更新」でしかなく、初回登録時に何らかの理由で壊れた値が、上書きされずにキャッシュされ続けていたと考えられます。

この登録情報の実体は/data/system/配下のシステムファイルにあると推測されますが、これは非root環境のShizuku特権シェルからも完全にアクセス不能な領域でした。root権限を持たない、という開発方針を貫く以上、この「壊れたキャッシュそのもの」を直接消す手段は存在しません。

解決策は、正面から直すのではなく、別人になりすますことでした。ウィジェットプロバイダーのクラス名をCatWidgetProviderからCatWidgetProviderV2に変更します。パッケージ名は同じでも、クラス名が変われば、システムから見て「一意のキー」が変わり、まっさらな新しいプロバイダーとして登録されます。ゾンビ化した古い登録情報を迂回する、いわば引っ越しによる解決です。

これでようやく、狙い通りの2x2サイズが実現しました。

J. 第二の壁:動きがいつの間にか止まる

サイズ問題が解決してひと安心したのも束の間、今度は「最初は動いていたまばたきが、しばらくすると止まる」という現象に直面しました。

ここで思い出したのが、これまでの自作アプリ開発(AppTrigger、TrackTrace)で何度も遭遇してきた、HyperOSお馴染みの挙動でした。バックグラウンドで動くプロセスを、システムが積極的に凍結・終了させてしまうというものです。今回もまさにこれが原因でした。

Eで書いたHandler.postDelayed方式は、アプリのプロセスが生きている間だけ動くタイマーです。ウィジェットプロバイダーは基本的に、システムから呼び出された瞬間だけ短時間起動し、用が済めばすぐにプロセスごと畳まれます。最初の1〜2回のまばたきが動いて見えたのは、プロセスがまだ生きていた「猶予期間」にたまたま収まっていただけで、システムがプロセスを終了させた瞬間、Handlerごとタイマーが消滅していました。

過去のアクセシビリティサービス系トラブルであれば「Foreground Service化」という定石が効きましたが、ウィジェットプロバイダーをForeground Serviceのように常駐させるのは、Android設計思想的にもバッテリー消費の観点でも筋が悪いと言えます。ここで採った解決策は、プロセスの生死に依存しない、システム標準の時報機構に処理を委ねるというものでした。

AlarmManagerを使います。アプリのプロセス内で時間を数えるのではなく、「〇ミリ秒後にもう一度自分を起こしてほしい」とシステムに予約を入れる方式に切り替えました。

alarmManager.setAndAllowWhileIdle(
        AlarmManager.ELAPSED_REALTIME_WAKEUP,
        SystemClock.elapsedRealtime() + delayMillis,
        pendingIntent);

これなら、たとえアプリのプロセスがシステムに終了させられていても、予約した時刻が来ればシステムがブロードキャストを届け、その瞬間だけアプリが目覚めて1回分のまばたき処理を行い、また次のアラームを予約して眠りに戻ります。「起き続ける」のではなく「その都度たたき起こしてもらう」設計に転換したことで、プロセスキルの影響を受けなくなりました。

K. 完成形の構造を振り返る

最終的な構成は次の4層になりました。

  1. 見た目:開いた目/閉じた目、2枚のベクター画像(.xml)
  2. 配置枠:ウィジェットのレイアウト(FrameLayout + ImageView)とサイズ定義(appwidget-provider)
  3. 司令塔:CatWidgetProviderV2(まばたき状態をSharedPreferencesに保存し、切り替えのたびにRemoteViewsを更新)
  4. 心臓:AlarmManagerによる自己再起動ループ(プロセスの生死に依存しない)

見た目のパーツ(1、2)だけを見れば数十分の作業ですが、実際に「ホーム画面で確実に動き続ける」ところまで持っていくには、システムのウィジェット登録キャッシュの壊れ方(I)と、バックグラウンドプロセスの生死管理(J)という、2つの見えない壁を越える必要がありました。

Z. まとめ:遠回りの先にあったもの

今回の一番の学びは、「動いた」と「動き続ける」の間には、Androidシステムの内部構造への理解という深い溝があるということでした。

  • サイズが直らない壁は、「アプリの設定ミス」ではなく「システム側のキャッシュの持ち方」を疑って初めて突破できました
  • まばたきが止まる壁は、「コードのロジックミス」ではなく「Androidのプロセスライフサイクルとバッテリー最適化の設計思想」を理解して初めて突破できました

どちらも、表面的な症状(サイズが変わらない、動きが止まる)から一段掘り下げて、なぜそれが起きるのかという機序にまで踏み込まなければ、根本的な解決には辿り着けませんでした。

PCを使わない、という縛りは、この過程を難しくした部分もあります(aapt2バイナリの罠や、APKインストール時のサンドボックスの壁は、PCを使っていれば遭遇しなかった可能性が高いです)。しかし同時に、この記事に書いたウィジェットの構造的な問題(ゾンビ登録キャッシュ、プロセスキル対策)そのものは、開発環境を問わず誰もが踏みうる、Android開発の本質的な難所でもあります。

もし今、スマホ一台しか手元になくて、それでも何かを作りたいと思っている方がこの記事を読んでいるなら、お伝えしたいのはこれです。遠回りの道でも、目的地には着けます。そして、その遠回りの途中で拾った小石(今回で言えばdumpsys appwidgetでの調査手法や、AlarmManagerへの切り替えという設計判断)は、どんな環境で開発している方にとっても、意外と価値のある道標になるはずです。

次にホーム画面で何か「動くアイコン」を見かけたときは、その裏に、こういう小さな攻防が隠れているかもしれません。

Googleフォトを開かずにバックグラウンド同期させるdaemonを作る ― 仕組みの発見から、作り方・使い方まで完全ガイド

はじめに

POCO F8 Pro + Termuxという、PCを一切使わない環境で開発を続けております。今回は、Googleフォトアプリを一度も開かずに、写真を撮った瞬間にバックグラウンドで自動バックアップさせるdaemonの作り方をご紹介します。

Googleフォトの自動バックアップは便利な機能ですが、実際に検証してみると、バッテリー節約のためかバックグラウンドでのチェック頻度が絞られており、「アプリを開いた瞬間にまとめてバックアップが走る」という挙動になりがちです。今すぐバックアップしたいのに、アプリを開かない限り反映されない——この記事では、その制約を回避する方法と、実際にこの記事だけを読めば同じものが作れるよう、コードと手順を余すところなく掲載します。

仕組みの発見: Googleフォトの内部レシーバーを探す

まず、Googleフォトを画面に表示させずにバックアップ処理だけを走らせる方法を探るところから始めます。Android のアプリは、外部からの操作を受け付ける「レシーバー」や「サービス」を内部に複数持っていることが多く、Shizuku権限(root不要でADB相当の操作ができる仕組み)を使えば、これらを直接呼び出せる場合があります。

まず、Googleフォトが持っているレシーバー・サービスの一覧を確認します。

"$HOME/bin/rish" -c "dumpsys package com.google.android.apps.photos" 2>/dev/null | grep -A 3 "Service\|Receiver" | head -60

この出力の中に、次のような有望なレシーバーが見つかりました。

com.google.android.apps.photos/.jobqueue.JobServiceBroadcastReceiverInternal
アクション: com.google.android.apps.photos.jobqueue.EXECUTE_JOBS

名前の通り、「保留中のジョブ(バックアップ処理を含む)を実行させる」ためのレシーバーに見えます。これに向けてブロードキャストを送ってみます。

"$HOME/bin/rish" -c "am broadcast -a com.google.android.apps.photos.jobqueue.EXECUTE_JOBS -n com.google.android.apps.photos/.jobqueue.JobServiceBroadcastReceiverInternal"

実行するとBroadcast completed: result=0と表示され、ブロードキャスト自体は正常に受け付けられます。ただしresult=0は「レシーバーが何もresultCodeを設定しなかった」ことを示すだけで、これだけでは本当にバックアップが走ったかどうかは分かりません。

実機で新しい写真を撮影した直後にこのコマンドを実行し、Googleフォトを開かずにバックアップが完了しているかを確認したところ、正しくバックアップジョブがトリガーされていることを確認できました。アプリを一度も開かずに、内部レシーバーへのブロードキャストだけでバックアップを開始させられる、というのがこの記事の核心の発見です。

本体の実装: photos_backup_trigger.sh

この発見をもとに、写真フォルダの変化を監視し、新しいファイルができるたびに自動でこのブロードキャストを送るdaemonを作ります。

#!/data/data/com.termux/files/usr/bin/bash

WATCH_DIRS=(
    "/storage/emulated/0/DCIM"
    "/storage/emulated/0/Pictures"
)
LOG_FILE="$HOME/logs/photos_backup_trigger.log"
RISH="$HOME/bin/rish"
COOLDOWN=60
LAST_TRIGGER=0

mkdir -p "$HOME/logs"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 📸 photos_backup_trigger.sh 起動" | tee -a "$LOG_FILE"

inotifywait -r -m -e create,moved_to "${WATCH_DIRS[@]}" 2>/dev/null | while read -r DIRECTORY EVENT FILE; do

    NOW=$(date +%s)
    ELAPSED=$(( NOW - LAST_TRIGGER ))

    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🔍 新規検知: $DIRECTORY $EVENT $FILE" >> "$LOG_FILE"

    if [ "$ELAPSED" -lt "$COOLDOWN" ]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⏳ クールダウン中 (残 $(( COOLDOWN - ELAPSED ))秒) — スキップ" >> "$LOG_FILE"
        continue
    fi

    LAST_TRIGGER=$NOW
    sleep 2

    "$RISH" -c "am broadcast -a com.google.android.apps.photos.jobqueue.EXECUTE_JOBS -n com.google.android.apps.photos/.jobqueue.JobServiceBroadcastReceiverInternal" >> "$LOG_FILE" 2>&1
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🚀 Googleフォト バックアップジョブをトリガーしました" >> "$LOG_FILE"
    termux-notification --title "📸 新規写真を検知" --content "$(basename "$FILE") のバックアップをトリガーしました" 2>/dev/null
done

コードのポイント解説

監視対象: DCIM(カメラで撮影した写真)とPictures(スクリーンショットなど)の2フォルダを、inotifywait -r -mで再帰的・継続的に監視します。

検知イベント: create(新規作成)とmoved_to(リネーム完了)の両方を監視します。カメラアプリは撮影直後、.pending-...という一時ファイル名で書き込みを始め、完了すると正式なファイル名にリネームすることが多いため、両方のイベントを拾っておくのが安全です。

クールダウン(60秒): 連写した場合など、短時間に何度もファイルが作られても、60秒に1回だけブロードキャストを送るようにしています。バックアップジョブは1回トリガーすれば保留中のファイルをまとめて処理してくれるため、毎回送る必要はありません。無駄な処理とログの氾濫を防ぐための工夫です。

sleep 2: ブロードキャスト送信前に2秒待機します。ファイルの書き込みが完全に終わるのを待つための、簡易的な安全マージンです。

通知: 検知してトリガーした瞬間に、termux-notificationで「📸 新規写真を検知」という通知を出します。これにより、daemonが実際に働いていることを目視で確認できます。

監視役の実装: photos_trigger_watchdog.sh

daemonは常駐している以上、いつ何らかの理由(強制終了、メモリ不足など)で停止してもおかしくありません。「止まったこと自体に気づけない」のは、daemon運用における見落としがちなリスクです。 これを解消するため、photos_backup_trigger.shの生死を定期的に確認し、死んでいたら通知を出して自動再起動する、別の監視daemonを用意します。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/photos_trigger_watchdog.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

LOG_FILE="$HOME/logs/photos_trigger_watchdog.log"
TARGET_SCRIPT="photos_backup_trigger.sh"
CHECK_INTERVAL=300

mkdir -p "$HOME/logs"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 💓 photos_trigger_watchdog.sh 起動" | tee -a "$LOG_FILE"

while true; do
    if ! pgrep -f "$TARGET_SCRIPT" > /dev/null; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ ${TARGET_SCRIPT}が停止しています。再起動します" >> "$LOG_FILE"
        termux-notification --title "❌ 写真バックアップ監視が停止" --content "${TARGET_SCRIPT}を自動再起動しました" 2>/dev/null
        nohup bash "$HOME/scripts/$TARGET_SCRIPT" > /dev/null 2>&1 &
    fi
    sleep "$CHECK_INTERVAL"
done

5分(300秒)おきにpgrepで対象daemonの生死を確認し、死んでいれば通知を出しつつ自動で再起動します。ロック機構(mkdir+trap)も入れてあるので、多重起動の心配もありません。

「なぜ止まらなかったのか」を追った先に見つかった、隠れた起動経路

実は、このphotos_backup_trigger.shは今回watchdogを作るまで、何度端末を再起動しても、ずっと動き続けていました。 この理由を調べたところ、意外な事実が判明しました。

fortress(daemon管理システム)にも、master_daemon.sh(Termux:Boot経由で起動されるdaemon群)にも、このスクリプトは登録されていませんでした。にも関わらず端末のrebootを何度も経験しながら生き延びていたのは——.bashrcに直接、起動コマンドが書き込まれていたからでした。

bash "$HOME/scripts/photos_backup_trigger_start.sh" > /dev/null 2>&1

.bashrcはTermuxで新しいセッション(ターミナルタブ)を開くたびに毎回読み込まれるファイルです。呼び出されるphotos_backup_trigger_start.sh側では、二重起動を防ぐチェックが入っています。

#!/data/data/com.termux/files/usr/bin/bash
if pgrep -f "scripts/photos_backup_trigger.sh" > /dev/null; then
    echo "photos_backup_trigger は既に稼働中です"
    exit 0
fi

nohup bash "$HOME/scripts/photos_backup_trigger.sh" >> "$HOME/logs/photos_backup_trigger_nohup.log" 2>&1 &
disown
echo "photos_backup_trigger 起動しました (PID=$!)"

つまり、Termuxを触るたびに毎回「生きているか確認し、死んでいたら起動する」というセルフチェックが働いていたわけです。これが、正規のdaemon管理経路を通さなくても、これまでずっと動き続けていた理由でした。

しかしこれには弱点があります。.bashrcはシェルセッションが開始されて初めて読み込まれます。 もし端末を再起動した後、Termuxを一度も開かなければ(FortressHubアプリを開く、Termuxウィジェットを使うといったきっかけも含めて何も触らなければ)、.bashrcは一度も実行されず、このdaemonは人間がTermuxを触るまでずっと死んだままになります。

弱点の解消: Termux:Boot経由の起動へ統合する

この弱点を解消するには、端末の起動が完了した瞬間に、Termuxアプリを一切開かなくても自動的にスクリプトを実行してくれるTermux:Boot(~/.termux/boot/)経由の起動に乗せる必要があります。この環境では、Termux:Bootからmaster_daemon.shが呼ばれる構成になっているので、そこに登録します。

master_daemon.shの該当部分に、以下のように2行追加します。

start_daemon "photos_backup_trigger" \
    "$HOME/scripts/photos_backup_trigger.sh"

start_daemon "photos_trigger_watchdog" \
    "$HOME/scripts/photos_trigger_watchdog.sh"

そして、もう不要になった.bashrc側の起動行は削除し、起動経路を一本化します。

sed -i '/photos_backup_trigger_start.sh/d' ~/.bashrc

これで、端末再起動後にTermuxを一切開かなくても、Termux:Boot→master_daemon.shという経路で自動的に両方のdaemonが立ち上がるようになりました。

おまけ: master_daemon.sh自体への自己修復機能の追加

master_daemon.shのstart_daemon関数は、PIDファイルによる生存確認は行っていましたが、daemon自身が持つmkdirロック(~/daemon_locks/[name].lock)が、kill -9のような強制終了によって残留してしまうケースまでは面倒を見ていませんでした。この場合、ロックが残ったままだと次回の起動が失敗してしまいます。

そこで、以下の関数を追加し、起動の直前に「ロックはあるが実際のプロセスは死んでいる」状態を検知して自動的に片付けるようにしました。

clear_stale_daemon_lock() {
    local name="$1"
    local script="$2"
    local stale_lock="$HOME/daemon_locks/${name}.lock"

    if [ -d "$stale_lock" ] && ! pgrep -f "$script" > /dev/null; then
        rmdir "$stale_lock" 2>/dev/null
        log "古いロックを自動削除: $stale_lock"
    fi
}

start_daemon関数内の、nohup bash "$script" ...で実際に起動する直前にこの関数を呼び出すよう1行追加するだけで組み込めます。

    clear_stale_daemon_lock "$name" "$script"

    nohup bash "$script" >> "$log_file" 2>&1 </dev/null &

作り方・使い方: 手順まとめ

ここまでの内容を、実際に手を動かして構築する手順として整理します。

前提条件

  • Termux(F-Droid版推奨)、inotify-toolsパッケージ(pkg install inotify-tools)
  • Termux:API アプリ + termux-apiパッケージ(通知機能に必要)
  • Shizuku(root不要でADB相当の権限を使うためのツール)がセットアップ済みで、rishコマンドが使える状態であること

手順1: photos_backup_trigger.shを作成する

cat > ~/scripts/photos_backup_trigger.sh << 'EOF'
#!/data/data/com.termux/files/usr/bin/bash

WATCH_DIRS=(
    "/storage/emulated/0/DCIM"
    "/storage/emulated/0/Pictures"
)
LOG_FILE="$HOME/logs/photos_backup_trigger.log"
RISH="$HOME/bin/rish"
COOLDOWN=60
LAST_TRIGGER=0

mkdir -p "$HOME/logs"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 📸 photos_backup_trigger.sh 起動" | tee -a "$LOG_FILE"

inotifywait -r -m -e create,moved_to "${WATCH_DIRS[@]}" 2>/dev/null | while read -r DIRECTORY EVENT FILE; do

    NOW=$(date +%s)
    ELAPSED=$(( NOW - LAST_TRIGGER ))

    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🔍 新規検知: $DIRECTORY $EVENT $FILE" >> "$LOG_FILE"

    if [ "$ELAPSED" -lt "$COOLDOWN" ]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⏳ クールダウン中 (残 $(( COOLDOWN - ELAPSED ))秒) — スキップ" >> "$LOG_FILE"
        continue
    fi

    LAST_TRIGGER=$NOW
    sleep 2

    "$RISH" -c "am broadcast -a com.google.android.apps.photos.jobqueue.EXECUTE_JOBS -n com.google.android.apps.photos/.jobqueue.JobServiceBroadcastReceiverInternal" >> "$LOG_FILE" 2>&1
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🚀 Googleフォト バックアップジョブをトリガーしました" >> "$LOG_FILE"
    termux-notification --title "📸 新規写真を検知" --content "$(basename "$FILE") のバックアップをトリガーしました" 2>/dev/null
done
EOF
chmod +x ~/scripts/photos_backup_trigger.sh

手順2: 監視役 photos_trigger_watchdog.shを作成する

cat > ~/scripts/photos_trigger_watchdog.sh << 'EOF'
#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/photos_trigger_watchdog.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

LOG_FILE="$HOME/logs/photos_trigger_watchdog.log"
TARGET_SCRIPT="photos_backup_trigger.sh"
CHECK_INTERVAL=300

mkdir -p "$HOME/logs"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] 💓 photos_trigger_watchdog.sh 起動" | tee -a "$LOG_FILE"

while true; do
    if ! pgrep -f "$TARGET_SCRIPT" > /dev/null; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ ${TARGET_SCRIPT}が停止しています。再起動します" >> "$LOG_FILE"
        termux-notification --title "❌ 写真バックアップ監視が停止" --content "${TARGET_SCRIPT}を自動再起動しました" 2>/dev/null
        nohup bash "$HOME/scripts/$TARGET_SCRIPT" > /dev/null 2>&1 &
    fi
    sleep "$CHECK_INTERVAL"
done
EOF
chmod +x ~/scripts/photos_trigger_watchdog.sh

手順3: 動作確認する

まず両方を手動でバックグラウンド起動し、動くか確認します。

nohup bash ~/scripts/photos_backup_trigger.sh > /dev/null 2>&1 &
disown
nohup bash ~/scripts/photos_trigger_watchdog.sh > /dev/null 2>&1 &
disown

実際にスクリーンショットを撮るか写真を1枚撮影し、「📸 新規写真を検知」という通知が届くか確認してください。

手順4: Termux:Boot経由での自動起動に組み込む

既にこの環境でmaster_daemon.sh(Termux:Boot経由で起動するdaemon統括スクリプト)を運用している場合は、その末尾に以下を追加します。

start_daemon "photos_backup_trigger" \
    "$HOME/scripts/photos_backup_trigger.sh"

start_daemon "photos_trigger_watchdog" \
    "$HOME/scripts/photos_trigger_watchdog.sh"

もしまだmaster_daemon.shのような仕組みがなければ、Termux:Bootアプリを導入した上で、~/.termux/boot/配下に以下のようなスクリプトを直接置く方法でも構いません。

mkdir -p ~/.termux/boot
cat > ~/.termux/boot/10-photos-backup.sh << 'EOF'
#!/data/data/com.termux/files/usr/bin/bash
nohup bash "$HOME/scripts/photos_backup_trigger.sh" > /dev/null 2>&1 &
nohup bash "$HOME/scripts/photos_trigger_watchdog.sh" > /dev/null 2>&1 &
EOF
chmod +x ~/.termux/boot/10-photos-backup.sh

手順5: 実際に端末を再起動して確認する

ここが最も重要な最終確認です。端末を実際にrebootし、Termuxアプリを一切開かずに、しばらく待ってから写真を1枚撮ってみてください。 通知が届けば、Termux:Boot経由の自動起動が正しく機能している証拠です。

まとめ

この記事でご紹介した仕組みは、大きく3つの要素で成り立っています。

  1. Googleフォトの内部レシーバーへの直接ブロードキャストによって、アプリを開かずにバックアップジョブをトリガーする
  2. inotifywaitによる写真フォルダの監視 + クールダウンで、新規写真を検知するたびに無駄なく確実にトリガーする
  3. watchdogによる生死監視と、Termux:Boot経由での確実な自動起動によって、端末再起動やプロセスの強制終了があっても、人間が気づく前に自動で復旧する

特に3番目は、単に「daemonを1つ作って動かす」だけでは見落としがちな部分です。今回のように「なぜか止まらずに動き続けていた」daemonの謎を追ったことで、隠れた依存関係(.bashrc経由の起動)とその弱点が明らかになり、結果としてより堅牢な仕組みに作り直すことができました。同じような常駐処理を作る際の参考になれば幸いです。


この記事は、PCを一切使わず、POCO F8 Pro + Termuxの環境だけで実装・検証・記事執筆まで完結させております。PCが使えない、あるいは使わない選択をされた方のための実践記録として書いております。

「勝手に動く仕組み」同士がぶつかった日 ― Android自動化の落とし穴と、たどり着いた自己修復という答え

はじめに

POCO F8 Pro + Termuxという、PCを一切使わない環境で開発を続けております。今回は、これまで作り込んできた複数のdaemon(常駐スクリプト)たちが、お互いの縄張りを踏み荒らして事故を起こした一日の記録です。最終的には「防ぐ」から「壊れても自動で直る」という発想の転換にたどり着きました。

技術的な結論だけでなく、そこに至るまでの回り道と、その過程で見えてきた「iOSとAndroidの設計思想の違い」についても書き残しておきます。

発端: 自動仕分けdaemonが、生きているファイルを誤って掴む

以前の記事でご紹介したdownload_organizer.sh(Downloadフォルダの自動仕分けdaemon)を運用していたところ、自作アプリYTDownloaderの「自動アップロード」設定が、時間を指定してオンにしても、いつの間にかオフに戻ってしまう現象に遭遇しました。

調査を進めると、原因はソースコードの中にありました。

private static final String CONFIG_PATH = "/storage/emulated/0/Download/app_config.json";

YTDownloaderの設定は、Downloadフォルダ直下にapp_config.jsonという名前で保存されていました。これは.json拡張子です。そしてdownload_organizer.shの振り分けルールには、こう書かれていました。

txt|json|log) echo "DevLogs" ;;

設定を保存した直後、download_organizer.shがそれを「新しいJSONファイルが完成した」と誤認し、DevLogs/フォルダへ移動してしまっていたのです。 次にアプリを起動した時、Download/app_config.jsonは既に存在せず、初期値に戻る——という単純ながら気づきにくいバグでした。

同じパターンが、次々に見つかる

一つ見つかると、同じ構造の問題が芋づる式に出てきました。

  • cookies.txt(YouTube認証用Cookie) — Cookie更新ツールがDownload直下に保存し続けているファイルが、.txt拡張子ゆえに移動されていた
  • dashboard_data.json — 監視ダッシュボード用にcronが毎分更新するファイルが、更新のたびに「新規ファイル完成」と誤認され、数秒間で重複ファイルを10個近く生成していた
  • settingslauncher_command.txt / settingslauncher_result.txt — ショートカット作成アプリがコマンドの受け渡しに使うファイルが、同様に繰り返し掴まれていた

これらはすべて、「一度きり完成して終わるファイル」を前提にしたdownload_organizer.shの設計と、「継続的に上書きされ続けるファイル」という実態が食い違っていたことが原因でした。都度、除外リストへの追加で個別に塞いでいきましたが、この対症療法には限界があります。除外リストは「既に知っているファイル名」にしか効かず、今後新しく作るアプリが同じ場所に設定ファイルを置けば、また同じ事故が起きるからです。

なぜiOSでは、この種の事故が起きなかったのか

ここで、以前iOS Tweak開発をしていた頃との対比が頭に浮かびました。iOSでは、こうした「ファイルの奪い合い」は一度も経験したことがありませんでした。

理由を整理すると、プラットフォームの設計思想そのものの違いに行き着きます。

iOSは「1つの正解」を強制する。 Tweakの設定ファイルは/var/mobile/Library/Preferences/[Bundle ID].plistという、ほぼ一択の場所に決まっています。Appleが強く縛っているぶん自由度は低いですが、その代わり一度覚えてしまえば、どのアプリを触ってもほぼ同じ場所を探せば見つかるという再現性がありました。

Androidは「選択肢」を用意して開発者に委ねる。 getFilesDir()、SharedPreferences、getExternalFilesDir()、そして「開発者が好きな絶対パスを直接指定する」という、今回のYTDownloaderのような選択肢まで、すべてが許容されています。この自由度こそがAndroidの設計思想ですが、代償として「このアプリの設定はどこにあるんだっけ」を毎回調べるコストが常に発生します。

そして今回のトラブルシューティングでは、まさにこのコストを直接体験しました。「自動アップロードが戻る」という症状から、原因のapp_config.jsonにたどり着くまでに、アプリの再インストール履歴の確認、shared_prefsフォルダの捜索、daemonのログの精査、そして最終的にソースコードのgrepという、遠回りの道のりを経る必要がありました。もしiOSであれば、Bundle IDさえ分かればplistファイルへ一直線に行けたはずです。

対策1: iOS的な発想を取り入れる ― アプリ専用の堅固な場所へ

この対比から得た結論はシンプルでした。「Downloadフォルダのような共有領域に設定ファイルを置く」という判断自体が、今回の事故の土壌になっていた。 ならば、iOSのサンドボックス的な発想を取り入れ、外部から一切触れない場所に置き直すべきだと考えました。

Androidにおける「最も堅固な場所」はContext.getFilesDir()が指すアプリ専用の内部ストレージです。ここは他のアプリ(root権限がない限り)は一切アクセスできません。

修正は、static finalの文字列定数だったパスを、Contextに依存するメソッドへ置き換える形で行いました。

// 変更前
private static final String CONFIG_PATH = "/storage/emulated/0/Download/app_config.json";

// 変更後
private String getConfigPath() {
    return new File(getFilesDir(), "app_config.json").getAbsolutePath();
}

呼び出し側もすべてCONFIG_PATHからgetConfigPath()に置き換え、ビルド・実機確認を経て、これでdownload_organizer.shを含むどんな外部プロセスからも触れない場所に設定ファイルが移りました。根本解決です。

対策2: 想定外の脇道 ― Gradleキャッシュの罠

この修正のビルド中に、本題とは無関係な脇道に迷い込みました。ビルドが以下のエラーで失敗したのです。

AAPT2 aapt2-8.5.0-11315950-linux Daemon #0: Unexpected error output: 
.../aapt2: 2: Syntax error: "(" unexpected

原因は、この環境特有の事情にありました。Gradleが自動でダウンロードしてくるaapt2(Android用リソースコンパイラ)は、汎用Linux向けのビルドであり、Termuxのファイルシステム構成では実行できません。これを回避するため、この環境では以前からfix_gradle_aapt2.shというスクリプトを用意し、Termux向けにビルドされた正常なaapt2でキャッシュ内のファイルを上書きする運用にしていました。

今回、ビルドエラーへの対処として~/.gradle/cachesを丸ごと削除してしまったのが失敗でした。キャッシュを空にすると、次にビルドした瞬間、Gradleが「Termuxでは動かない汎用版」を新たにダウンロード・展開してしまいます。 つまり、キャッシュを消す→ビルドで壊れた版が復活→fix_gradle_aapt2.shで差し替え→もう一度ビルド、という余計な往復が発生してしまいました。

「防ぐ」ことの限界を、実地検証で確かめる

「今後また同じことが起きないように、Gradleに勝手にダウンロードさせない方法はないか」という発想で、いくつかの対策を試しました。結果を正直に記録しておきます。

試したこと1: chattr +i(イミュータブル属性) 一般ユーザー権限はもちろん、Shizuku経由のシステム権限(uid=2000)でもPermission deniedで拒否されました。root権限がない環境では使えません。

試したこと2: chmod 555(書き込み権限の剥奪) ファイル単体へのcpによる上書きは防げることを確認しました(fix_gradle_aapt2.sh自身がPermission deniedで失敗)。しかし、Gradleが「壊れたファイルを検知して自動的に別の方法で用意し直す」という挙動までは防げませんでした。

試したこと3: --offlineフラグ 理論上は「新規のネットワークダウンロードを禁止する」はずのフラグですが、実際に検証すると、ローカルに既にキャッシュされている依存関係情報からの再展開までは防げませんでした。 ネットワークアクセスの禁止と、ローカルキャッシュからの再構築は別物だった、ということです。

試したこと4: ビルド前の事前チェック 「ビルドを始める前にfix_gradle_aapt2.shを毎回実行しておく」という先回り策も試しましたが、これには構造的な問題がありました。そもそもファイルが存在しない状態からは、差し替えようがないのです。汎用版のダウンロード・展開自体はGradleのビルドプロセスの内部で発生するため、外側から先回りしてブロックすることができませんでした。

正直に言うと、この一連の検証はどれも「防ぐ」ことに失敗しました。少し回り道でしたが、これらすべてを実地で確かめたからこそ、次の発想にたどり着けたと思っています。

発想の転換: 「防ぐ」のではなく「壊れたら直して、やり直す」

ここで視点を変えました。壊れることを防げないなら、壊れた後に自動で直して、もう一度試せばいい。

これは、実は今回の環境で以前から実践してきた考え方そのものでした。fortressのdaemon群も、daemonが死んだら自動で再起動する仕組みを持っています。同じ発想をビルドスクリプトにも適用しました。

echo "[1/4] ビルド実行..."
cd "$PROJECT_DIR" && gradle assembleDebug --offline
if [ $? -ne 0 ]; then
    echo "⚠️  ビルド失敗。aapt2バイナリを差し替えて再試行します..."
    fix_gradle_aapt2.sh
    gradle assembleDebug --offline
    if [ $? -ne 0 ]; then
        echo "❌ ビルド失敗(再試行後も失敗)"
        exit 1
    fi
    echo "✅ 再試行でビルド成功"
fi

流れはこうです。

  1. 通常通りビルドを試みる
  2. もし失敗したら(aapt2が壊れていたら)、この時点で初めてファイルが存在する状態になっているので、fix_gradle_aapt2.shで確実に差し替えられる
  3. 差し替え後、自動でもう一度ビルドを試行する
  4. それでも失敗すれば、そこで初めて人間に知らせる

意図的にキャッシュを空にした状態から、実際にこの仕組みでビルドを走らせたところ、狙い通りの結果になりました。

1回目: BUILD FAILED (aapt2が壊れている)
⚠️ ビルド失敗。aapt2バイナリを差し替えて再試行します...
🔍 Gradleキャッシュ内のaapt2バイナリを検索中...
  → 差し替え: ...(自動修復)
2回目: BUILD SUCCESSFUL
✅ 再試行でビルド成功

人間が気づいて対処する前に、失敗と修復と再試行がすべて自動で完結しました。

まとめ ― 「自動化」という言葉の、もう一段上の意味

今回の一日を振り返ると、二つの異なる種類の「自動化」に向き合っていたことに気づきます。

一つは、「daemonを常駐させて、勝手に仕事をこなしてくれる」自動化です。これは以前の記事で扱ったDownload自動仕分けdaemonのような話で、今回はこの種の自動化同士が衝突するという、新しい形の問題を経験しました。

もう一つは、今回たどり着いた、「壊れても、人間が気づく前に自動で直って、何事もなかったかのように前に進む」自動化です。これは単に「作業を自動化する」より一段上の考え方で、失敗そのものを織り込んだ設計だと言えます。

Androidの自由度の高さは、今回のような「設定ファイルの置き場所の事故」を生みやすい土壌でもありますが、同時に、シェルスクリプト一つで挙動を細かく制御し、こうした自己修復の仕組みを後付けできる柔軟さも持っています。iOSのような一貫性を最初から持たない代わりに、こうやって自分で一貫性と頑健さを組み上げていく作業自体が、この環境で開発を続ける面白さなのだと思います。


この記事は、PCを一切使わず、POCO F8 Pro + Termuxの環境だけで実装・検証・記事執筆まで完結させております。PCが使えない、あるいは使わない選択をされた方のための実践記録として書いております。

Androidスマホ単体でdaemonを作る方法(実践編) ― 4パターン×3シチュエーションのサンプルコード集

この記事について

前回の記事「[Androidスマホ単体でdaemon(常駐スクリプト)を作る方法 ― パターン別に一から解説します]」では、Termux環境で常駐daemonを作る際の4つの基本パターン(ポーリング型、イベント駆動型、cron型、安定確認付きポーリング)をご紹介しました。

今回はその続編として、それぞれのパターンについて実際のシチュエーションを3つずつ想定し、そのまま使えるサンプルコードをご用意しました。前回の記事で説明した「ロック機構」「trapによるクリーンアップ」の基本は当然の前提として、すべてのサンプルに組み込んでいます。まだお読みでない方は、先に前回の記事に目を通していただくと、なぜこの形になっているかがより深く理解できるかと思います。

各サンプルは、変数名や監視対象パスを書き換えるだけで、そのままご自身の環境に流用いただけるように書いています。


パターン1: ポーリング型のシチュエーション例

「今の状態」を一定間隔でチェックする方式です。以下の3つの場面を想定しました。

シチュエーション1-A: ストレージ空き容量の監視

場面: スマホ単体で開発をしていると、ビルドキャッシュやログの肥大化で気づかないうちにストレージが逼迫します。空き容量が一定を下回ったら通知を出したいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/storage_watch.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

LOG_FILE="$HOME/logs/storage_watch.log"
mkdir -p "$HOME/logs"

THRESHOLD_GB=10        # このGBを下回ったら通知
CHECK_INTERVAL=1800    # 30分おきに確認
LAST_NOTIFIED=0
NOTIFY_COOLDOWN=21600  # 一度通知したら6時間は再通知しない

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 💽 storage_watch.sh 起動" | tee -a "$LOG_FILE"

while true; do
    FREE_GB=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 空き容量: ${FREE_GB}GB" >> "$LOG_FILE"

    if [ "$FREE_GB" -lt "$THRESHOLD_GB" ]; then
        NOW=$(date +%s)
        ELAPSED=$(( NOW - LAST_NOTIFIED ))
        if [ "$ELAPSED" -ge "$NOTIFY_COOLDOWN" ]; then
            termux-notification --title "⚠️ ストレージ逼迫" \
                --content "空き容量が${FREE_GB}GBです(閾値${THRESHOLD_GB}GB)"
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] 通知送信済み" >> "$LOG_FILE"
            LAST_NOTIFIED=$NOW
        fi
    fi

    sleep "$CHECK_INTERVAL"
done

ポイント: 通知そのものにもクールダウン(NOTIFY_COOLDOWN)を設けています。単純に「閾値を下回ったら毎回通知」にしてしまうと、30分おきに何度も同じ通知が飛んできて煩わしくなります。「一度通知したらしばらく黙る」仕組みは、ポーリング型のdaemonで通知を扱う際によく使う工夫です。

シチュエーション1-B: Wi-Fi接続状態の監視と自動再接続

場面: 自宅Wi-Fiの電波が不安定で、気づくとモバイルデータ通信に切り替わっていることがあります。切断を検知したら再接続を試みたいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/wifi_watch.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

LOG_FILE="$HOME/logs/wifi_watch.log"
mkdir -p "$HOME/logs"

TARGET_SSID="MyHomeWiFi"
CHECK_INTERVAL=60

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 📶 wifi_watch.sh 起動" | tee -a "$LOG_FILE"

while true; do
    CURRENT_SSID=$(termux-wifi-connectioninfo | jq -r '.ssid // ""' | tr -d '"')

    if [ "$CURRENT_SSID" != "$TARGET_SSID" ]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⚠️ 対象Wi-Fiに未接続(現在: ${CURRENT_SSID:-なし})" >> "$LOG_FILE"
        # Android標準のWi-Fi再スキャンをトリガー(要termux-api)
        termux-wifi-scaninfo > /dev/null 2>&1
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] 再スキャンを実行" >> "$LOG_FILE"
    fi

    sleep "$CHECK_INTERVAL"
done

ポイント: termux-wifi-connectioninfoはTermux:APIパッケージ(pkg install termux-api)に含まれるコマンドで、現在接続中のSSIDやIPアドレスをJSON形式で取得できます。Androidの制約上、Termuxから直接特定のWi-Fiへ強制接続することはできませんが、再スキャンのトリガーや、状態を記録して後から傾向を分析する用途には十分使えます。

シチュエーション1-C: 特定プロセスの心拍監視(シンプル版)

場面: 前回の記事で紹介したsupervisorパターンほど大掛かりでなくてよいので、「この1つのdaemonが生きているかどうか」だけを簡易的に見張りたいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/heartbeat_watch.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

LOG_FILE="$HOME/logs/heartbeat_watch.log"
mkdir -p "$HOME/logs"

TARGET_SCRIPT="cookies_monitor.sh"
CHECK_INTERVAL=120

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 💓 heartbeat_watch.sh 起動(監視対象: $TARGET_SCRIPT)" | tee -a "$LOG_FILE"

while true; do
    if ! pgrep -f "$TARGET_SCRIPT" > /dev/null; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ❌ ${TARGET_SCRIPT}が停止しています。再起動します" >> "$LOG_FILE"
        nohup bash "$HOME/scripts/$TARGET_SCRIPT" > /dev/null 2>&1 &
        termux-notification --title "daemon自動復旧" --content "${TARGET_SCRIPT}を再起動しました"
    fi

    sleep "$CHECK_INTERVAL"
done

ポイント: 前回紹介したsupervisorパターンは複数daemonをまとめて管理する仕組みですが、「特定の1つだけ確実に見張りたい」場合は、このように単機能のシンプルな監視daemonを個別に用意する方が構成がわかりやすくなることもあります。管理対象が増えてきたらsupervisorへ統合する、という段階的な移行も現実的な選択肢です。


パターン2: イベント駆動型のシチュエーション例

ファイルシステムの変化をinotifywaitでリアルタイムに検知する方式です。

シチュエーション2-A: スクリーンショットの自動リネーム・振り分け

場面: スクリーンショットフォルダにScreenshot_20260811_123456.pngのような機械的な名前でファイルが溜まっていきます。撮影日でサブフォルダ分けしたいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/screenshot_organizer.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'pkill -9 -P $$ 2>/dev/null; rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

WATCH_DIR="$HOME/storage/shared/Pictures/Screenshots"
LOG_FILE="$HOME/logs/screenshot_organizer.log"
mkdir -p "$HOME/logs"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 📸 screenshot_organizer.sh 起動" | tee -a "$LOG_FILE"

inotifywait -m -e close_write,moved_to --format '%f' "$WATCH_DIR" 2>/dev/null | while read -r FILE; do
    [ -z "$FILE" ] && continue
    SRC="$WATCH_DIR/$FILE"
    [ -f "$SRC" ] || continue

    # ファイル名から日付部分(YYYYMMDD)を抽出してサブフォルダを決定
    DATE_PART=$(echo "$FILE" | grep -oE '[0-9]{8}' | head -1)
    [ -z "$DATE_PART" ] && continue

    DEST_DIR="$WATCH_DIR/$DATE_PART"
    mkdir -p "$DEST_DIR"

    if mv "$SRC" "$DEST_DIR/$FILE" 2>>"$LOG_FILE"; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ 振り分け: $FILE → $DATE_PART/" >> "$LOG_FILE"
    fi
done

ポイント: スクリーンショットは通常「一度保存されたら二度と書き換わらない」ファイルなので、イベント駆動型との相性が良い典型例です。grep -oE '[0-9]{8}'でファイル名から日付らしき8桁の数字を抜き出し、サブフォルダ名として使っています。

シチュエーション2-B: 設定ファイルの変更を検知して自動リロード

場面: 複数のdaemonが参照している共通設定ファイル(~/configs/app.conf)を編集した際、いちいち手動で全daemonを再起動するのが面倒なケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/config_reloader.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

WATCH_FILE="$HOME/configs/app.conf"
LOG_FILE="$HOME/logs/config_reloader.log"
mkdir -p "$HOME/logs" "$(dirname "$WATCH_FILE")"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🔄 config_reloader.sh 起動(監視対象: $WATCH_FILE)" | tee -a "$LOG_FILE"

inotifywait -m -e close_write "$WATCH_FILE" 2>/dev/null | while read -r DIRECTORY EVENT FILE; do
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🔍 設定ファイル変更を検知" >> "$LOG_FILE"

    # 設定ファイルを参照している各daemonを再起動
    for TARGET in rclone_daemon.sh realtime_backup.sh system_monitor_daemon.sh; do
        pkill -f "$TARGET" 2>/dev/null
        sleep 1
        nohup bash "$HOME/scripts/$TARGET" > /dev/null 2>&1 &
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🔁 再起動: $TARGET" >> "$LOG_FILE"
    done
done

ポイント: 監視対象がディレクトリではなく単一ファイルである点にご注目ください。inotifywaitは監視対象にファイルを直接指定することもできます。設定変更のたびに手動でdaemon群を再起動する手間から解放される、実用性の高いパターンです。

シチュエーション2-C: ログファイルへの特定キーワード追記を検知して通知

場面: 別のdaemonが吐き出しているエラーログを常時監視し、「ERROR」という文字列が追記されたら即座に通知してほしいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/error_notifier.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'pkill -9 -P $$ 2>/dev/null; rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

TARGET_LOG="$HOME/logs/rclone_daemon.log"
LOG_FILE="$HOME/logs/error_notifier.log"
mkdir -p "$HOME/logs"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🚨 error_notifier.sh 起動(監視対象: $TARGET_LOG)" | tee -a "$LOG_FILE"

# modify イベント発生のたびに、追記された最新行だけをチェックする
tail -n0 -F "$TARGET_LOG" 2>/dev/null | while read -r LINE; do
    if echo "$LINE" | grep -qi "error\|エラー"; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⚠️ エラー検知: $LINE" >> "$LOG_FILE"
        termux-notification --title "⚠️ エラー検知" --content "$LINE"
    fi
done

ポイント: このシチュエーションだけ、あえてinotifywaitではなくtail -n0 -Fを使っています。tail -Fは「ファイル末尾への追記だけをリアルタイムに拾う」ことに特化したコマンドで、ログファイルの監視においてはinotifywaitより簡潔に書けます。-n0で「起動時点での既存内容は読み飛ばす」、-Fで「ファイルがローテーションされても追従し続ける」ことを指定しています。「イベント駆動」という考え方は同じでも、目的によってはinotifywait以外のツールが適していることもある、という一例です。


パターン3: cron型のシチュエーション例

決まった時刻・間隔で単発処理を実行する方式です。

シチュエーション3-A: 日次のログローテーション

場面: daemon群のログファイルが際限なく肥大化するのを防ぐため、毎日決まった時刻に古いログを圧縮・アーカイブしたいケースです。

#!/data/data/com.termux/files/usr/bin/bash
# ~/scripts/log_rotate.sh ― crontabから1日1回呼び出される想定

LOG_DIR="$HOME/logs"
ARCHIVE_DIR="$HOME/logs/archive"
MAX_SIZE_KB=10240   # 10MBを超えたログをローテーション対象にする

mkdir -p "$ARCHIVE_DIR"

for LOGFILE in "$LOG_DIR"/*.log; do
    [ -f "$LOGFILE" ] || continue
    SIZE_KB=$(du -k "$LOGFILE" | awk '{print $1}')

    if [ "$SIZE_KB" -gt "$MAX_SIZE_KB" ]; then
        BASENAME=$(basename "$LOGFILE" .log)
        ARCHIVE_NAME="${BASENAME}_$(date '+%Y%m%d_%H%M%S').log.gz"
        gzip -c "$LOGFILE" > "$ARCHIVE_DIR/$ARCHIVE_NAME"
        : > "$LOGFILE"   # 元のログは空にする(daemonを止めずに済む)
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ローテーション: $BASENAME → $ARCHIVE_NAME"
    fi
done

# 30日以上前のアーカイブは削除
find "$ARCHIVE_DIR" -name "*.log.gz" -mtime +30 -delete
# crontabに登録(毎日午前4時に実行)
0 4 * * * /data/data/com.termux/files/home/scripts/log_rotate.sh >> /data/data/com.termux/files/home/logs/log_rotate.log 2>&1

ポイント: : > "$LOGFILE"でログファイルの中身だけを空にし、ファイル自体は削除していません。daemon側は既に開いているファイルディスクリプタに書き込み続けているため、ファイルを削除してしまうと出力先を見失ってしまいます。稼働中のdaemonを止めずにログを整理する、実用上重要なテクニックです。

シチュエーション3-B: 定時ヘルスチェックレポートの送信

場面: システム全体の状態(daemon生死、ストレージ、バッテリー)を毎朝1回まとめて通知してほしいケースです。

#!/data/data/com.termux/files/usr/bin/bash
# ~/scripts/morning_report.sh ― crontabから毎朝呼び出される想定

RUNNING_COUNT=0
TOTAL_COUNT=0
for SCRIPT in rclone_daemon.sh realtime_backup.sh system_monitor_daemon.sh cookies_monitor.sh; do
    TOTAL_COUNT=$((TOTAL_COUNT + 1))
    pgrep -f "$SCRIPT" > /dev/null && RUNNING_COUNT=$((RUNNING_COUNT + 1))
done

FREE_GB=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
BATT_PCT=$(termux-battery-status | jq -r '.percentage')

REPORT="🏰 朝の状況報告
Daemon稼働: ${RUNNING_COUNT}/${TOTAL_COUNT}
空き容量: ${FREE_GB}GB
バッテリー: ${BATT_PCT}%"

termux-notification --title "おはようございます" --content "$REPORT"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $REPORT" >> "$HOME/logs/morning_report.log"
# crontabに登録(毎朝7時に実行)
0 7 * * * /data/data/com.termux/files/home/scripts/morning_report.sh

ポイント: cron型は「複数の状態をまとめて1回だけチェックし、サマリーとして報告する」用途に非常に向いています。ポーリング型のように継続的に監視するのではなく、「1日1回、まとめて確認する」という割り切りが、通知の煩わしさを抑えつつ必要な情報を得る良いバランスになります。

シチュエーション3-C: 深夜帯限定の重い処理

場面: 大量のファイルを対象にした処理(全体のバックアップ整合性チェックなど)を実行したいが、日中に走らせると端末の動作が重くなるため、深夜の使っていない時間帯だけ実行したいケースです。

#!/data/data/com.termux/files/usr/bin/bash
# ~/scripts/nightly_integrity_check.sh ― crontabから深夜のみ呼び出される想定

LOG_FILE="$HOME/logs/nightly_check.log"
TARGET_DIR="$HOME/MyAndroidApps"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🌙 整合性チェック開始" >> "$LOG_FILE"

ERROR_COUNT=0
for PROJECT in "$TARGET_DIR"/*/; do
    [ -d "$PROJECT/.git" ] || continue
    cd "$PROJECT" || continue

    if ! git fsck --quiet 2>>"$LOG_FILE"; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ⚠️ 破損の疑い: $PROJECT" >> "$LOG_FILE"
        ERROR_COUNT=$((ERROR_COUNT + 1))
    fi
done

if [ "$ERROR_COUNT" -gt 0 ]; then
    termux-notification --title "⚠️ 整合性チェック" --content "${ERROR_COUNT}件の異常を検出しました"
fi

echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ 整合性チェック完了(異常${ERROR_COUNT}件)" >> "$LOG_FILE"
# crontabに登録(深夜3時に実行、負荷の高い処理を人が使わない時間帯に寄せる)
0 3 * * * /data/data/com.termux/files/home/scripts/nightly_integrity_check.sh

ポイント: git fsckで各プロジェクトのGitリポジトリに破損がないかをチェックしています。crontabの分 時 日 月 曜日欄を調整するだけで「特定の時間帯だけ」「特定の曜日だけ」実行するよう簡単に制御できるのが、cron型ならではの強みです。処理内容そのものは重くても、人が使わない時間に押し込めることで体感の影響を最小化できます。


パターン4: 安定確認付きポーリングのシチュエーション例

複数ステップを経て完成するファイルや、他プロセスとの競合が起きやすい場面向けの方式です。

シチュエーション4-A: ダウンロード完了した動画への後処理

場面: yt-dlpで動画をダウンロードしていると、ダウンロード中の一時ファイル(.part拡張子など)を掴んで処理してしまい、不完全なファイルを壊してしまう危険があります。完全にダウンロードが終わった動画ファイルだけを検知して、サムネイル生成などの後処理をかけたいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/video_postprocess.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

WATCH_DIR="$HOME/storage/shared/Movies/YTDownloader"
LOG_FILE="$HOME/logs/video_postprocess.log"
POLL_INTERVAL=10
STABLE_CHECKS=3   # 30秒間サイズが変化しなければ「ダウンロード完了」とみなす

mkdir -p "$HOME/logs"
declare -A LAST_SIG
declare -A STABLE_COUNT

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🎬 video_postprocess.sh 起動" | tee -a "$LOG_FILE"

while true; do
    sleep "$POLL_INTERVAL"

    for FILE_PATH in "$WATCH_DIR"/*.mkv "$WATCH_DIR"/*.mp4; do
        [ -f "$FILE_PATH" ] || continue
        FILE="$(basename "$FILE_PATH")"

        # yt-dlpの一時ファイル(.part, .ytdl)は最初から対象外にする
        case "$FILE" in
            *.part|*.ytdl) continue ;;
        esac

        SIG="$(stat -c '%s_%Y' "$FILE_PATH" 2>/dev/null)"
        [ -z "$SIG" ] && continue

        if [ "${LAST_SIG[$FILE]}" = "$SIG" ]; then
            STABLE_COUNT[$FILE]=$(( ${STABLE_COUNT[$FILE]:-0} + 1 ))
        else
            STABLE_COUNT[$FILE]=1
            LAST_SIG[$FILE]="$SIG"
        fi

        if [ "${STABLE_COUNT[$FILE]}" -ge "$STABLE_CHECKS" ]; then
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ ダウンロード完了と判定: $FILE" >> "$LOG_FILE"

            THUMB="${FILE_PATH%.*}_thumb.jpg"
            ffmpeg -y -i "$FILE_PATH" -ss 00:00:05 -vframes 1 "$THUMB" 2>>"$LOG_FILE"
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🖼️ サムネイル生成: $(basename "$THUMB")" >> "$LOG_FILE"

            unset LAST_SIG[$FILE]
            unset STABLE_COUNT[$FILE]
        fi
    done
done

ポイント: .partや.ytdlのような、ダウンローダーが使う一時ファイルの拡張子をcase文で明示的に除外している点が実用上重要です。安定確認の仕組みだけに頼らず、「明らかに未完成であるとわかる拡張子」は最初からふるいにかけておくことで、無駄な待機時間を減らせます。

シチュエーション4-B: 分割ダウンロードされる複数ファイルが揃うのを待つ

場面: あるツールが、1つの成果物を複数のパートファイル(archive.zip.001、archive.zip.002...)に分割してダウンロードするケースです。全パートが揃うまでは結合処理を始められません。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/split_archive_merger.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

WATCH_DIR="$HOME/storage/shared/Download"
LOG_FILE="$HOME/logs/split_archive_merger.log"
POLL_INTERVAL=10
STABLE_CHECKS=2

mkdir -p "$HOME/logs"
declare -A LAST_SIG
declare -A STABLE_COUNT

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 📦 split_archive_merger.sh 起動" | tee -a "$LOG_FILE"

while true; do
    sleep "$POLL_INTERVAL"

    # ベース名(拡張子の連番を除いた部分)ごとにグループ化して処理する
    for FIRST_PART in "$WATCH_DIR"/*.zip.001; do
        [ -f "$FIRST_PART" ] || continue
        BASE_NAME="${FIRST_PART%.001}"
        GROUP_KEY="$(basename "$BASE_NAME")"

        # このベース名の全パートの合計サイズを署名にする
        TOTAL_SIZE=0
        PART_COUNT=0
        for PART in "${BASE_NAME}".[0-9][0-9][0-9]; do
            [ -f "$PART" ] || continue
            SIZE=$(stat -c '%s' "$PART" 2>/dev/null || echo 0)
            TOTAL_SIZE=$(( TOTAL_SIZE + SIZE ))
            PART_COUNT=$(( PART_COUNT + 1 ))
        done
        SIG="${TOTAL_SIZE}_${PART_COUNT}"

        if [ "${LAST_SIG[$GROUP_KEY]}" = "$SIG" ]; then
            STABLE_COUNT[$GROUP_KEY]=$(( ${STABLE_COUNT[$GROUP_KEY]:-0} + 1 ))
        else
            STABLE_COUNT[$GROUP_KEY]=1
            LAST_SIG[$GROUP_KEY]="$SIG"
        fi

        if [ "${STABLE_COUNT[$GROUP_KEY]}" -ge "$STABLE_CHECKS" ]; then
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ 全パート到着と判定: $GROUP_KEY (${PART_COUNT}分割)" >> "$LOG_FILE"

            cat "${BASE_NAME}".[0-9][0-9][0-9] > "${BASE_NAME}_merged.zip"
            echo "[$(date '+%Y-%m-%d %H:%M:%S')] 🔗 結合完了: ${GROUP_KEY}_merged.zip" >> "$LOG_FILE"

            unset LAST_SIG[$GROUP_KEY]
            unset STABLE_COUNT[$GROUP_KEY]
        fi
    done
done

ポイント: 単一ファイルではなく「関連する複数ファイルの集合」に対して安定確認を行う応用パターンです。個々のパートファイルのサイズではなく、グループ全体の合計サイズとパート数を"署名"として扱うことで、「まだ次のパートが届いていない」状態と「全パートが揃って静止した」状態を区別しています。

シチュエーション4-C: 連続スクリーンショットが撮り終わるのを待ってから一括処理

場面: 操作説明のために連続でスクリーンショットを何枚も撮ることがあります。1枚ごとに反応するのではなく、「一連の連続撮影が完全に終わってから」まとめて連番リネームしたいケースです。

#!/data/data/com.termux/files/usr/bin/bash
LOCKDIR=~/daemon_locks/screenshot_batch_rename.lock
mkdir -p ~/daemon_locks
if ! mkdir "$LOCKDIR" 2>/dev/null; then
    exit 0
fi
trap 'rmdir "$LOCKDIR" 2>/dev/null; exit 0' EXIT INT TERM HUP

WATCH_DIR="$HOME/storage/shared/Pictures/Screenshots"
LOG_FILE="$HOME/logs/screenshot_batch_rename.log"
POLL_INTERVAL=5
STABLE_CHECKS=4   # 20秒間、新しいスクリーンショットが増えなければ「撮影終了」とみなす

mkdir -p "$HOME/logs"
LAST_COUNT=-1
STABLE_ROUND=0

echo "[$(date '+%Y-%m-%d %H:%M:%S')] 📸 screenshot_batch_rename.sh 起動" | tee -a "$LOG_FILE"

while true; do
    sleep "$POLL_INTERVAL"

    # まだリネーム前(機械的な命名)のファイルだけを数える
    CURRENT_COUNT=$(find "$WATCH_DIR" -maxdepth 1 -name "Screenshot_*.png" | wc -l)

    if [ "$CURRENT_COUNT" -eq 0 ]; then
        STABLE_ROUND=0
        continue
    fi

    if [ "$CURRENT_COUNT" -eq "$LAST_COUNT" ]; then
        STABLE_ROUND=$(( STABLE_ROUND + 1 ))
    else
        STABLE_ROUND=1
        LAST_COUNT="$CURRENT_COUNT"
    fi

    if [ "$STABLE_ROUND" -ge "$STABLE_CHECKS" ]; then
        echo "[$(date '+%Y-%m-%d %H:%M:%S')] ✅ 連続撮影の終了を検知(${CURRENT_COUNT}枚)。一括リネーム開始" >> "$LOG_FILE"

        BATCH_TAG="$(date '+%Y%m%d_%H%M%S')"
        N=1
        find "$WATCH_DIR" -maxdepth 1 -name "Screenshot_*.png" -print0 | sort -z | \
        while IFS= read -r -d '' FILE; do
            NEW_NAME="$(printf '%s_%03d.png' "$BATCH_TAG" "$N")"
            mv "$FILE" "$WATCH_DIR/$NEW_NAME"
            echo "[$(date '+%Y-%m-%d %H:%M:%S')]   → $NEW_NAME" >> "$LOG_FILE"
            N=$(( N + 1 ))
        done

        LAST_COUNT=-1
        STABLE_ROUND=0
    fi
done

ポイント: これまでの例は「個々のファイルのサイズ・更新時刻」を署名にしていましたが、この例では「対象となるファイルの"個数"」を安定確認の基準にしています。1枚1枚のスクリーンショットは撮影完了と同時にファイルサイズが確定してしまうため、サイズの安定確認だけでは「もう次の1枚が来ないこと」を判定できません。「個数が一定時間増えていないこと」を見ることで、「一連の連続撮影セッションが終わったこと」を検知できます。安定確認の"署名"に何を使うかは、シチュエーションに応じて柔軟に変えられるという好例です。


まとめ

今回ご紹介した12個のサンプルは、いずれも実際に起こりうる具体的な場面を想定して書きました。共通してお伝えしたいのは、「安定確認の署名に何を使うか」「除外すべき一時ファイルは何か」を、シチュエーションごとに考え抜く必要があるという点です。同じパターン4(安定確認付きポーリング)であっても、単一ファイルのサイズを見るのか、複数ファイルの合計を見るのか、ファイルの個数を見るのかで、書き方は変わってきます。

サンプルコードをそのままコピーして使っていただいても構いませんが、可能であれば「なぜこの署名の取り方を選んだのか」まで理解した上でご自身のシチュエーションに合わせて調整していただくと、応用が効くようになるかと思います。


この記事は、PCを一切使わず、POCO F8 Pro + Termuxの環境だけで実装・検証・記事執筆まで完結させております。PCが使えない、あるいは使わない選択をされた方のための実践記録として書いております。