Skip to content

feat: Android バックグラウンド通知(WebPush & UnifiedPush) #330

Open

Description

@hitalin

概要

WebSocket ベースのフォアグラウンドサービスによるリアルタイム通知を実装する。

現状

NotificationWorker.kt で15分間隔のポーリングのみ。最大15分の通知遅延が発生する。

ref: ROADMAP.md — 部分採用で完成していない機能

Activity

  1. added this to the v1.1.0+ milestone on Apr 9, 2026
  2. added
    ✨FeatureThis adds/improves/enhances a feature
    🗺RoadmapLong-term issue that may happen in far future
    on Apr 9, 2026
  3. modified the milestones: v1.1.0+, v1.0.0 on Apr 9, 2026
  4. self-assigned this
    on Apr 9, 2026
  5. changed the title [-]feat: Android バックグラウンド通知(WebSocket フォアグラウンドサービス)[/-] [+]feat: Android バックグラウンド通知(WebPush & UnifiedPush)[/+] on Apr 24, 2026
  6. hitalin commented on Apr 24, 2026

    @hitalin
    CollaboratorAuthor

    設計メモ: UnifiedPush + Misskey Web Push (2026-04-24 調査)

    WebSocket foreground service 方式から UnifiedPush + Misskey 既存 Web Push 方式に方針転換する提案。

    核心

    1. Misskey sw/register は任意 HTTPS endpoint を受け付ける(ドメイン検証なし、本家コード確認済) → サーバー改修不要
    2. UnifiedPush android-connector v3 は RFC 8291 復号まで自動 → Rust 側に Web Push crypto 実装不要
    3. embedded-fcm-distributor v3.0 で fcm flavor は FCM 同梱、nofcm flavor(F-Droid向け) は ntfy/Conversations 等の UP distributor 経由 → Google 依存をフレーバー単位で切り替え可能

    WebSocket foreground service を採らない理由

    • Android Doze mode で foreground service ごと殺される
    • バッテリー消費が大きい (CPU wakelock 常駐)
    • Play Store ポリシー的にグレー (foreground service 用途が限定的)
    • 一方で FCM/UnifiedPush は OS レベルで配信される → 確実かつ低コスト

    アーキテクチャ

    設計原則: Kotlin 直書き、独自 Tauri plugin を作らない、Rust↔Kotlin RPC なし

    理由:

    • Tauri plugin は AndroidManifest の intent-filter を自動追加できないため plugin 化の旨味が薄い
    • 本プロジェクトには既存の JNI / tauri::mobile plugin 利用例がゼロ
    • iOS 対応は未定のため cross-platform plugin のメリットも消滅
    • 既存 `NotificationWorker.kt` が `poll_accounts.json` ファイル共有パターンで動いており、それを踏襲できる
    [アカウント追加/設定変更時]
      Rust: export_account_list() → poll_accounts.json 更新
            (push_enabled, vapid_public_key を含める)
      Kotlin: FileObserver で検知
            → UnifiedPush.register(vapid=misskey_vapid)
            → onNewEndpoint: Misskey sw/register POST
    
    [Push 到着時]
      UnifiedPush distributor (FCM/ntfy/etc) → PushReceiver.onMessage
      → 復号済み Misskey JSON を受け取る
      → NotificationCompat で Android 通知表示
    
    [ログアウト時]
      Rust: poll_accounts.json からアカウント除去
      Kotlin: sw/unregister → UnifiedPush.unregister
    

    ファイル構成

    追加する Kotlin ファイル(全て `src-tauri/android/push/` 配下):

    • `PushReceiver.kt` — UnifiedPush MessagingReceiver
    • `MisskeyPushClient.kt` — sw/register + sw/unregister (既存 NotificationWorker の KeyStore 復号ロジック共有)
    • `NotificationRenderer.kt` — Misskey JSON → Android 通知変換
    • `PushTokenStore.kt` — endpoint/keys を KeyStore 暗号化保存
    • `AccountSyncObserver.kt` — poll_accounts.json の FileObserver

    Rust 側は `commands/mod.rs` の `export_account_list()` を拡張して `push_enabled` と `vapid_public_key` を含めるだけ(数行)。

    Gradle product flavor:

    productFlavors {
        create(\"fcm\") { dimension = \"store\" }  // embedded-fcm-distributor 同梱
        create(\"nofcm\") {
            dimension = \"store\"
            applicationIdSuffix = \".fdroid\"    // distributor 必須、UP のみ
        }
    }

    フェーズ分け

    Phase 内容
    0 PoC: UnifiedPush 実機受信確認 (手動 sw/register)
    1 Kotlin コア実装 (PushReceiver/MisskeyPushClient/NotificationRenderer/PushTokenStore/AccountSyncObserver)
    2 Rust: poll_accounts.json スキーマ拡張 + VAPID key 取得
    3 Frontend: 設定トグル (Android のみ表示)
    4 Product flavors (fcm/nofcm) + CI ビルド
    5 既存ポーリング (NotificationWorker) からの移行、fallback 設計

    リスク

    • UnifiedPush v3 の broadcast receiver が Tauri WebView と共存できるか → PoC で検証
    • Misskey `sw/register` の `publickey` フィールドのエンコード形式 (base64url? raw?) → 実装時に確認
    • カスタム絵文字リアクションは Android 通知で表示制約があるため文字列表示で妥協
    • 複数アカウント時の notification grouping は既存 NotificationWorker の実装を流用

    代替案との比較

    方式 実装量 遅延 バッテリー Google 依存
    現状 15 分ポーリング 既存 最大 15 分 中 なし
    WebSocket foreground service 多 即時 高 (Doze で殺される) なし
    UnifiedPush(fcm flavor) 少 即時 低 あり(Google Fonts 程度)
    UnifiedPush(nofcm flavor) 少 即時 低 なし
    自前 FCM 統合 + RFC 8291 復号 多 即時 低 あり

    参考: Milktea は独自 bridge server (PushToFCM) を運用しているが、2020 年当時は UnifiedPush v3 が未成熟だったため。2026 年時点では不要。

  7. modified the milestones: v1.0.0, v1.1.0+ on May 14, 2026
  8. hitalin commented on Jul 3, 2026

    @hitalin
    CollaboratorAuthor

    整理: v1.4.3 で Android の WebSocket ストリーミングが動作するようになったため、アプリ起動中のリアルタイム通知は復活済み。本イシューの残スコープは「アプリを閉じている間のバックグラウンド通知」のみで、現行の 15 分ポーリング (WorkManager) を UnifiedPush に置き換える話。設計は上のコメントのまま有効。着手時は Phase 0 の実機 PoC (UnifiedPush receiver と Tauri WebView の共存確認) から。

  9. hitalin commented on Jul 25, 2026

    @hitalin
    CollaboratorAuthor

    設計の再検証と更新 (2026-07-25)

    着手前に上の設計メモ (2026-04-24) を一次ソースで再検証した。方向性は今も有効だが、前提が古くなっている点と仕様の欠落があったので全面的に更新する。

    今も正しいこと

    • Misskey の Web Push 購読登録に endpoint の検証 (ドメイン allowlist・https チェック・パターン) は存在せず、任意の endpoint を受け付ける → サーバー改修不要
    • UnifiedPush の connector が RFC 8291 復号まで自動でやる → 復号済みの平文と「復号できたか」のフラグが渡ってくる。Rust 側に crypto 実装は不要
    • 独自 Tauri plugin を作らない判断はむしろ補強された (後述)
    • WebSocket foreground service を採らない判断は維持

    古くなっていた点

    1. API が変わっている: v3 では broadcast receiver ではなく PushService (Service) を継承する形が公式の実装形。エンドポイント通知時に URL と Web Push 公開鍵ペア (auth / p256dh) がそのまま渡され、Misskey 側が要求する 3 フィールドに 1:1 で対応する
    2. バージョン: connector は 3.3.3 (stable) が現行。上のメモの「3.0.0-rc1 が現行」は古い
    3. 同梱 FCM distributor は採らない (product flavor の分割も当然不要):
      • FOSS 版 embedded FCM distributor は deprecated になり本体に統合されたが、本体も実行時には端末の Google Play Services を利用する (登録要求を Google Services Framework へ投げ、c2dm の broadcast を受ける)。ビルド時の Google 依存はゼロだが「Google 非依存」ではない
      • 同梱をやめ、配信基盤は外部の distributor (ntfy など) に委ねる。理由:
        • Doze による遅延が消える。同梱 FCM の遅延は urgency 未指定に起因するが、常駐接続を持つ distributor はこの影響を受けない → 「即時通知」と言い切れるようになる
        • 同梱ライブラリのライセンスは LGPL-2.1 で、本体の AGPL-3.0 との組み合わせは議論の余地がある。使わなければこの論点自体が消える (connector 単体は Apache-2.0)
        • Play Services 依存が消え、F-Droid の anti-feature 判定リスクもなくなる
        • セルフホストした配信サーバーを使えば、通知経路が完全にユーザーの管理下に入る
      • サイズ削減は根拠にならない: 同梱をやめて減るのは約 63KB のみ。重量物は connector が依存する暗号ライブラリ (約 2.4MB) で、こちらはどちらにせよ残る
    4. 「本プロジェクトに JNI の利用例がゼロ」という前提はもう成り立たない: 現在はストリーミング経由の OS 通知が Rust から JNI で Kotlin 側の描画処理を呼んでいる。Rust ⇄ Kotlin をファイル共有で繋ぐ必要はない
      → 上のメモにあったエンドポイント受け渡し用の FileObserver とアカウント一覧ファイルのスキーマ拡張は不要

    通知経路を 2 本に統一し、ポーリングは廃止する

    現行のポーリングは Kotlin がアクセストークンを OS のキーストアから直接復号している。これはアプリ未起動でポーリングするための妥協で、この設計で最も筋の悪い部分。

    push 方式では Kotlin はトークンに一切触らない (購読登録は Rust 側、受信する payload は復号済み)。つまりポーリングを残す限りこの妥協だけが残り続ける。ポーリングを Rust 側に移すことはできない — 定期実行のワーカーはプロセスこそ起動するがアプリ本体のランタイムを持たないため、残す = トークン復号を Kotlin に残す、と同義になる。

    → push の動作を確認した上でポーリングを廃止する。

    • 「Kotlin は Misskey の認証情報を一切知らない」という不変条件が成立する
    • 通知の供給源が ストリーミング / push の 2 本になる
    • 定期実行ライブラリへの依存も外れる

    トレードオフとして、distributor を導入していない端末やサーバーが Web Push を無効にしているインスタンスではアプリを閉じている間の通知が無くなる。ただし現行のポーリングも Doze 中は端末の maintenance window に従うため、実際には数十分から数時間遅れの通知であり、通知としての価値は既に低い。β 段階で後方互換を残さない方針とも整合する。

    なおストリーミングを push に一本化する案は採らない。ストリーミングはタイムライン更新も担っていて通知専用の経路ではなく、アプリ起動中に外部の配信基盤を経由させるのは遅延も無駄も増えるため。供給源は 2 本のまま、描画を 1 箇所に統一するのが妥当な落とし所。

    仕様として押さえるべき点

    A. 重複の抑止は「フラグ 1 つ」で完結する

    • push 側: ストリーミング接続中を示すフラグが立っていれば何もしない。それだけ
    • ストリーミング側: 既存のフォーカス抑止をそのまま使う (新規実装は不要)

    フラグはアプリと同一プロセス上の静的な状態として持つ。プロセスが消えれば自動的に倒れるため、「アプリが死んでいるか」を別途判定する必要がない。

    この形にすると「プロセスは生きているが接続が切れている」場合 (ネットワーク断、Doze でのソケット切断) にもフラグが倒れているので push が描画し、通知を取りこぼさない。共有設定ファイルにフラグを置くと死亡時に古い値が残るため、静的な状態であることが要件。

    B. 通知 ID の採番を Misskey の通知 ID 由来に統一する

    現在のストリーミング経路は連番で採番しており、Misskey の通知 ID による重複排除が Android の通知 ID に反映されていない。両経路が同じ通知を拾った場合に 2 件表示される。ID を統一すれば後着が上書きするだけで済み、A の抑止が漏れた場合の保険になる。

    C. 通知描画を 1 箇所に統一する

    現在のリッチ通知描画は Activity のインスタンスメソッドで、アプリ未起動時には呼べない。application context ベースの静的な描画ヘルパーに切り出し、ストリーミングと push の両経路が同じ描画を共有する。Activity 側のメソッドは残して中身を委譲するだけにすれば、Rust 側の呼び出しは一切変更不要。

    D. エンドポイントの取得は非同期であることを前提にする

    配信基盤への登録要求を出しても、エンドポイントはその場では返らない (数秒後に非同期で届く)。「登録を指示した直後に取りに行く」設計は成立しない。

    → 初回とホスト追加時のみ、Rust 側が登録指示後に短時間だけ取得をリトライする (数十秒でタイムアウトし、次回起動でリトライ)。あわせて VAPID 公開鍵を Kotlin 側にキャッシュしておき、2 回目以降は起動時に Kotlin が自律的に再登録する。この場合エンドポイントは起動直後に揃うので、Rust は読むだけでよい。

    E. ログアウト時の順序制約

    アクセストークンを破棄する前に、認証付きで購読解除を済ませる必要がある。解除エンドポイントは認証が任意で、未認証で呼ぶとユーザーで絞られず同じエンドポイントに紐づく全アカウントの購読が消える — ホスト単位でエンドポイントを共有する設計では、1 アカウントのログアウトが同ホストの他アカウントの通知を止める事故になる。逆に解除し損ねるとエンドポイントは失効しないため通知が届き続け、こちらも自然には直らない。

    F. 通知のグルーピング方針を決める

    ストリーミング経路は「Android は通知チャンネル側でまとまるため集約しない」としている一方、廃止するポーリング経路は独自にグループサマリを作っていた。描画を統一する際にどちらの方針で行くか決める。

    制約

    1. サーバー側が Web Push を有効にしていないと通知は飛ばない。Service Worker 機能が無効、または VAPID 鍵ペアが未設定のインスタンスでは送信処理が silent に no-op になる → そのインスタンスはアプリを閉じている間の通知が無い状態になる
    2. push の中身は切り詰められる。ノート本文は要約に置換され、CW・リプライ元・リノート元は削除、通知種別ではノートの投稿者情報も落ちる (通知の発生元ユーザー自体は残る) → 詳細が要る場面は受信後に API で取り直す
    3. 配信失敗が Gone を返すとサーバー側で購読が自動削除される → 再登録の経路が必須
    4. VAPID: 登録失敗理由として「VAPID 必須」が返るケースのハンドリングが要る。また Android 6.0 未満は VAPID 非対応
    5. 即時性は distributor 次第: Misskey は送信時に urgency を指定しておらず、web push ライブラリの既定は normal。FCM 系の経路では normal 相当となり Doze 中にバッチ遅延しうるが、常駐接続を持つ distributor なら影響を受けない

    配信基盤 (distributor) の扱い

    同梱しない。外部の distributor に委ねる。

    端末の状態 挙動
    distributor が 1 つ 自動選択。ユーザー操作は不要
    複数 connector 側が持つ選択の仕組みに委譲する (自前の選択 UI は作らない)
    ゼロ アプリを閉じている間の通知は無し。導入すると即時通知になる旨だけ案内する

    専用の設定画面は作らない。通知の制御設定をアプリに持たない方針 (#747) を維持し、状態表示が要る場合は既存の「接続」に 1 行置くに留める。有効化トグルも設けない — distributor を導入している事実が実質的なオプトインであり、止めたい場合は OS の通知設定で止められる。

    設計の詳細

    購読の単位は「ホスト単位」

    • 購読登録の重複判定はユーザー単位なので、同一ホストの複数アカウントは同じエンドポイントを共有できる
    • 一方 VAPID 公開鍵はインスタンスごとに異なり、配信基盤への登録は登録単位ごとに別の鍵を渡せる
    • → ホストごとに 1 登録とし、受信した通知に含まれるユーザー ID でアカウントを振り分ける

    VAPID 鍵のローテーション検知

    • 購読登録のレスポンスに毎回サーバーの公開鍵が返るため、登録時に使った鍵と比較するだけで検知できる。サーバーメタを都度取り直す必要はない

    既読の同期 (任意)

    • 購読時に既読メッセージの送信を有効にすると、他デバイスで通知を既読にした際にその通知が push で届く → Android 側の通知を自動でキャンセルできる。push の総量が増えるためオプション扱い

    チャット

    • 新着チャットメッセージも push の対象に含まれるため、あわせて拾う

    責務分割 — 購読登録は Rust 側

    • Kotlin: 配信基盤への登録実行、push の受信、通知の描画。認証情報には触らない
    • Rust: VAPID 公開鍵の取得、購読の登録・解除・再登録、鍵ローテーション検知

    理由: アクセストークンは OS キーチェーンが正規の経路であり、購読登録はアプリ起動中に済ませられるため、Kotlin にトークンを触らせる理由がない。複数アカウント分の登録状態管理はロジックが厚く、型付きの層に置いた方が保守しやすい。

    トレードオフは、アプリ未起動中にエンドポイントが更新された場合に次回起動まで通知が届かない空白期間が生じること。ローテーションは稀なため許容する。

    なお、購読登録に必要なサーバーの VAPID 公開鍵は既存のサーバーメタ取得コマンドがそのまま使える (生 JSON を返すため)。notecli 側の改修は不要。

    フェーズ

    Phase 内容
    0 実機 PoC: 外部 distributor を入れた端末でエンドポイントを取得できるか、実際に Misskey から push が届いて復号できるか
    1 通知描画を共通の静的ヘルパーに切り出す + 通知 ID の採番統一 (先行リファクタ、Rust 側は無変更)
    2 Kotlin: push 受信サービス + 登録実行
    3 Rust: VAPID 公開鍵取得、購読の登録・解除・再登録、鍵ローテーション検知、ログアウト順序
    4 重複抑止のフラグを入れ、ポーリングと定期実行ライブラリへの依存を削除
    5 Frontend: 状態表示と案内 (既存の「接続」に最小限。専用の設定画面もトグルも作らない)

    旧 Phase 4 (product flavors) は同梱をやめたため削除。Phase 1 を先行リファクタとして独立させた。

    検証

    push は E2E ハーネス (#702) では検証できないため、手動の確認項目を成果物に含める: distributor の有無、アプリの前面 / 背面 / プロセス死、接続が切れている状態、複数アカウント、同一ホストの複数アカウント、ログアウト、Web Push 未設定のインスタンス。

  10. hitalin commented on Oct 22, 2026

    @hitalin
    CollaboratorAuthor

    Android の通知 — 設計の全面改訂 (2026-10-22)

    このコメントが現在の正本。 2026-07-25 の設計メモ (「接続中フラグで抑止」「ポーリング廃止」「endpoint の受け渡しはポーリングのリトライ」) はこの版で置き換える。2026-04-24 / 07-25 の判断のうち残すもの (distributor 非同梱、独自 plugin を作らない) は本文に理由ごと再掲してある。

    前提: 本家 Misskey が変わること・変えることは期待しない。本家への提案 (issue / PR) も出さない (2026-10-24)。 本家の変更に依存する項目は計画に入れない。push は本家を変えずに成立する形 (登録だけ利用者が本家 Web で行う、§5) で計画に入れる。

    2026-07-25 の設計を、本家ソースと現行コードを読み直し、4 視点 (セキュリティ / Android と本家仕様 / アーキテクチャ / 製品価値) の敵対的レビューを 3 巡通して書き直した。採用理由と不採用理由を両方残す。

    用語: ワーカー = アプリ終了中に OS が定期的に起こすポーリングのジョブ (今の Android 版にある仕組み)。背面経路 = アプリが前面に無いときに通知を届ける経路 (ワーカー、push)。三つ組 = push 購読の endpoint URL + auth + 公開鍵 (p256dh)。処理位置 = 端末側が口座 × 種別ごとに持つ「最後に処理した通知 ID」(サーバー側の既読位置とは別物)。状態行 = 「接続」設定の各サーバー行に出す背面経路の状態 1 行。

    0. ブロッカー: 本家の Web Push 購読登録はアプリのトークンでは呼べない

    • 本家の sw/register / sw/show-registration / sw/update-registration は「ログイン本体のネイティブトークン」限定 (secure)。MiAuth やアプリ発行のトークンは全部アクセストークンなので ACCESS_DENIED になる。2023-12-27 のセキュリティ修正 (GHSA-7pxq-6xx9-xpgm / CVE-2023-52139) で入り、sw/register への差分はこの 1 行だけ (権限の一括棚卸し)。NoteDeck の認証は MiAuth のみ
    • つまり UnifiedPush 以前に、Misskey の Web Push をサードパーティアプリが購読する手段が今の本家には無い。NoteDeck 固有の問題ではない
    • sw/unregister だけは認証任意・権限指定なしで、アクセストークンでも未認証でも呼べる (2026-09-30 から三つ組が必須、endpoint は https 必須)

    採らない回避策

    • パスワードログインでネイティブトークンを取る: ネイティブトークンは「ネイティブ限定」の操作 (トークン再生成、連携アプリの一覧と失効、ログイン履歴、各種エクスポート / インポート、アカウント移行) が通る。パスワードを第三者アプリが受け取る時点で MiAuth の意義 (渡す権限を絞る) を否定し、2FA / パスキー利用者にはサインイン UI 一式が要り、失効はトークン再生成で全セッションを巻き込む
    • 自前の中継サーバー: 作者のサーバー停止で全員の通知が止まる。meta: notecored をサーバーで常駐させ、NoteDeck をリモートクライアントとしても動かせるようにする #1106 でリモート構成を採らなかった判断と同じ
    • WebSocket の foreground service: Doze で切られ、電池を食い、Play の foreground service 用途制限に触れる

    結論

    • 本家の変更は期待しない。本家への提案 (issue / PR) も出さない。 仮に提案するなら sw/register を write:notifications 権限で許す形で、メッセージ本文が read:chat 無しで漏れる論点が付くが、それはこの issue の計画には含めない
    • 閉じている間の通知の実体はワーカーで、これを不変条件を満たす形に作り直す (§4)。前面はストリーミング、背面はワーカー、で設計を閉じる
    • その上に push を口座ごとのオプトインとして載せる (§5)。購読登録は本家 Web の Scratchpad で利用者に AiScript を 1 回実行してもらう (Web セッションのネイティブトークンで呼ばれるのでネイティブ限定を通る)。これは Flutter 製クライアント Aria が 2024-10 の初版から使っている手口で、本家改修もパスワードも要らない。push は「ワーカーを速くする」だけで、描画・同一性・既読・出し分けはワーカーと共通

    1. 目的と不変条件

    目的: Android でアプリを閉じている間・背面にいる間も、Misskey の通知とメッセージの新着が OS 通知として届く。届くまでの遅れは配送経路で決まり、経路の違いを利用者に説明できる。

    1. Kotlin は Misskey の認証情報を一切知らない。 トークンに触るのは Rust (keychain) だけ
    2. 通知の文言・画像・遷移先の組み立ては 1 実装。 どの経路から来ても同じ入力から同じ通知が出る
    3. 同じ出来事は 1 件しか出ない。既読になったら消え、既読でないものは勝手に消えない (利用者がタップ / スワイプで消した分は除く)
    4. アプリに「通知設定」を新設しない (feat: 通知の制御設定 (ON/OFF・種別・DND・音量) #747)。制御は OS の通知チャネル・サーバー側の受信設定・配送アプリの有無に委ねる
    5. Google のサービスに依存しない。 F-Droid (feat: Android 版を F-Droid で配布する #881) と両立する

    2. 確認済みの前提 (2026-10-22)

    NoteDeck の現状:

    • 通知を配るストリーミングの main チャンネルは、通知 / メンションのカラムが張るときにしか張られない。メッセージカラムは通知を配らない専用の main を張る (fix: ポーリングモードと後から追加したアカウントでチャットの履歴一覧に新着が出ない #1223)。起動時に全口座を張る処理は無い。モバイルは active なカラム 1 枚しか mount しない。リアルタイムモードの永続設定が再起動後にバックエンドへ適用されない #1004 では「全口座の eager 接続」を避けている
    • Android では前面でも常に OS 通知を出す。メッセージの新着と「特定ユーザーの投稿」通知は OS 通知にしていない。通知 ID はプロセス内の連番で、Misskey の通知 ID と無関係
    • 描画は Activity を経由する JNI 呼び出しで、戻るキーで Activity を閉じてプロセスだけ残った状態で通知が届くと落ちる (Activity に紐づくネイティブ文脈が破棄済み。release ビルドは panic で即終了)
    • ワーカーは トークンを OS キーチェーン用クレートの内部形式から Kotlin が直接復号して取り出している (第三者クレートの内部表現への依存)。電池低下時は走らない (NoteDeck 自身が付けている制約)。username@host をログに出している
    • 既読化は実効的に「取得で既読」しか無い。 通知の取得は本家の既定 (取得で全既読) のまま呼んでいる。明示の既読 (notifications/mark-all-as-read) はバッジタップで呼ぶが、アプリの MiAuth は write:notifications を要求していないので権限エラーを握っている。バッジの未読数は 本家に存在しないエンドポイント を呼んでいて、常に失敗を 0 として扱っている
    • 通知チャネルの作成は Rust と Kotlin の 2 箇所。口座一覧の写しファイル (秘密なし) にはログアウト済みの口座とゲストも載る
    • 通知の権限要求は起動直後。deep link はホストから口座を引くとき先頭の口座に固定。メッセージの会話単位の deep link は無い。Android の cold start で deep link が効くかはコード上未保証
    • 端末外バックアップの除外設定なし。プライバシーポリシーは「VAPID キーをキーチェーンに格納」と書くが VAPID 秘密鍵はサーバーのもので端末には無い (今でも誤り)

    本家 (2026-10-12 時点):

    • 既読は「位置」1 本で通知ごとのフラグは無い。未読数とメッセージ未読の有無は i で取れる。通知作成の 2 秒後に既読位置より新しければ「未読あり」のイベントと (購読があれば) push を送る
    • 本家 Web は、ウィンドウが可視なら通知の到着時に WS の readNotification で既読にする (通知ビューを見ていなくても。権限検査なし)
    • Web Push の購読と配送の詳細は §5 冒頭
    • 本家 Web の Scratchpad の Mk:api は、トークンを省略するとその Web セッションのトークン (ネイティブ) で呼ぶ

    UnifiedPush の確認済みの事実は §5 冒頭に置く。

    3. 今すぐ変える: ストリーミングと既読 (前提の変更)

    順序は 3.2 → 3.1 (逆だとポーリングモードで全口座の定期取得が既読を進める)。

    3.1 通知を配る main チャンネルは、ログイン中の全口座について Rust の口座セッションが張る

    • カラムの mount ではなく口座セッション (起動 / 口座の追加・削除) に追随して Rust が張る。OS 通知の消費者は Rust であり、WebView が凍結中やカラム不在のときにも自動バックオフで張り直せるのは Rust だけ
    • 起動時は Rust が永続化されたモード (realtime / polling) を自分で読んでから張る (フロントの適用を待つと WS が一瞬開いてから閉じる)
    • 復帰とオフライン解除の明示トリガはフロント発のまま、カラムごとの再接続から「全口座の再接続 1 本」に変える
    • 背面では WS を閉じない。OS が凍結 / 切断するまで生かす (背面で届いた通知を即時に出すため。電池の代償はこの前提ごと受け入れる)
    • これで「アプリが前面なら全口座の通知イベントがストリーミングで届く」が常に成り立つ。デスクトップでも「通知カラムを置いていない口座の OS 通知が出ない」が直る (フォーカス中でも、通知カラムが可視でなければ出す。§3.3)
    • 撤回する判断: fix: ポーリングモードと後から追加したアカウントでチャットの履歴一覧に新着が出ない #1223「メッセージを開いただけで OS 通知を出さない」は、仕組みは残すが「通知を配る main はカラムが張るもの」という前提を捨てる。リアルタイムモードの永続設定が再起動後にバックエンドへ適用されない #1004 で避けた「全口座の eager 接続」は常態になる。代償はデスクトップで口座数ぶんの WS が常時開くこと。再接続は既存のバックオフに乗せる

    3.2 既読にするのは「見たとき」だけ

    • 可視の定義: ウィンドウにフォーカスがあり (Android は Activity が started)、カラムが viewport 内にある。mount しているだけ (Android の背面、デスクトップの画面外) は可視ではない。§3.3 の抑止条件と共有する
    • 既読にする契機: (a) その口座の通知カラムが可視になった (可視になったら取得を 1 回走らせる)、(b) 可視のまま新着が届いた、(c) 「全部既読」の明示操作
    • 手段 (既存トークンで通るものを使う): 通知の取得は原則「既読にしない」(復帰補填 / ポーリングモードの定期取得 / AI capability / HTTP API)。可視になった通知カラムが行う取得だけ「既読にする」 (= (a)、権限不要)。可視中の到着は WS の readNotification (= (b)、本家 Web と同じ、権限不要。ポーリングモードでは次の可視中の取得が既読にする)。明示の全部既読は write:notifications が要るので MiAuth の要求権限に足す。既存口座は再認可するまでこの操作を出さず、状態行の最小形 (段階 A で作る) で「再ログインが要る」を案内する。CLI は既定を据え置きフラグで選べるようにする
    • 理由: 今は復帰時の補填やポーリングで取得するたびに全部既読になる。見ていないものが既読になり、本家 Web や他端末の未読も消え、ワーカーの「未読の分だけ出す」(§4.2) も成立しない
    • 本家 Web との差: 本家 Web はウィンドウが可視なら到着時に既読にする。NoteDeck は「通知カラムを見たとき」にする。副作用: 通知カラムを置いていない / 画面外に置いているデスクトップ利用者は、本家 Web や他端末の未読バッジが通知カラムを見るまで残る。それは「見ていない」の正しい表示として受け入れる
    • バッジの未読数は i から取るように直す (本家に無いエンドポイントを呼んでいる現状の修正)

    3.3 OS 通知の出し分け

    アプリの状態 ストリーミング経路 背面経路 (ワーカー / push)
    前面 その口座の通知カラムが可視なら出さない (メッセージはその会話が可視なら出さない)。それ以外は出す (Android では OS 通知がアプリ内バナーの代わり。音もバッジも通知カラムが mount 中しか鳴らないので、一律抑止すると気づけない) ワーカーは走らない。push は、その口座のストリーミングが接続済みなら新着を捨て、未接続 (再接続のバックオフ中) なら出す。既読の通知は常に処理する
    背面 / 終了 / 凍結 口座の背面経路がワーカーなら出す (プロセスが生きている間だけ。出した分は処理位置を進める)。push なら出さない 出す (ワーカーは処理位置以降だけ)
    • 前面かどうかは Activity の開始 / 停止で立てる静的なフラグ (背面経路と同一プロセス、プロセスが死ねば消える)。背面の判定に接続状態を使わない: 凍結中はソケットが死んでいても「接続中」に見え、配送で解凍された瞬間に取りこぼす。前面では凍結が無いので接続状態を使ってよい
    • 「どの口座の通知カラム / どの会話が可視か」はフロントが Rust に知らせる (新規コマンド)。デスクトップも同じ規則にする (今のフォーカス中一律抑止をやめる。バースト集約はデスクトップに残す)
    • 分割画面や PiP では前面扱い。可視なので許容。解凍直後、ストリーミングが張り直されるまでの数秒間に届いた通知は OS 通知にならない (カラムの補填には出る)。許容、検証項目に入れる

    3.4 通知の同一性と消去

    • Android の通知は (タグ, ID) で識別する。タグ = ホスト + Misskey ユーザー ID + 種別 (通知 / メッセージ)、ID = Misskey の通知 ID (メッセージはメッセージ ID) のハッシュ。経路が重なっても後着が上書きするだけで 2 件にならず、別口座の通知を上書きしたりタップ先が入れ替わったりしない
    • 2 段階表示 (本文先行 → 画像で更新) の 2 回目は鳴らさず、利用者が既に消していれば更新しない
    • 消す契機は、既読の契機とサーバー側で既読になったと分かったときの両方: 通知カラムが可視になった / 全部既読の操作 / ストリーミングか push で既読通知を受けた / ワーカーがサーバーの未読数 0 を見た。前面に戻っただけでは消さない
    • 1 日より古い通知は出さない (配送アプリが溜めて再配送する分の対策、本家 SW と同じ)。未来側はずれていても捨てない
    • deep link に口座を載せる (?account=<Misskey ユーザー ID>)。付いていて、そのホストにトークンを持つ口座として実在しなければ開かず状態行へ誘導する (先頭の口座で開くと別口座から返信する事故に戻る)。付いていない既存リンク (コマンドパレット / AI 生成) は先頭の口座のまま
    • メッセージの会話単位の deep link を新設する。Android の cold start で deep link が効くことを実機項目に入れる
    • 更新初回に旧ビルドの通知 (連番 ID) を全部消す。旧チャネルの削除と同じ契機

    4. 今すぐ変える: 背面経路の土台

    アプリを閉じている間の通知の実体はワーカー。これを不変条件を満たす形に作り直す。push (§5) が入った口座でもワーカーは安全網として残り、push はそれを速くする。

    4.1 描画の組み立てを 2 層に分け、どの経路からも同じ関数を通す

    層 置き場 入力 出力 禁止
    組み立て (純関数) notecore 正規化済み通知 (既存の正規化を通した型) + 言語 + ホスト (タグ, ID) / 見出し / 本文 / deep link / アバター URL / 絵文字画像 URL / チャネル Tauri、Activity に紐づくネイティブ文脈、keychain、ネットワーク、非同期ランタイム (モジュール単位の lint で検査、既存の Tauri 非依存 lint を拡張)
    ローカル化と背景ランタイム 手元側 (src-tauri) 上の出力 画像のローカルパス 非同期ランタイム + HTTP クライアント + 画像キャッシュはプロセスで 1 つ。Tauri の起動時もワーカーも同じものを使う
    • 言語の入力は「設定ディレクトリ (ワーカーからは Kotlin が渡す) + OS の言語」。表示言語の設定の読み出しをパスを取る形に切り出し、無いときの既定もワーカーと Tauri で同じ関数を通す
    • 組み立てる種別に「特定ユーザーの投稿」とメッセージを足す (挙動変更)。今出していない種別 (ロール付与、エクスポート完了、メッセージ招待、トークン作成、予約投稿) は本家 SW が出すものとの差を明記した上で現状維持
    • 通知の描画経路は、どの経路でも Activity に依存しない。 Kotlin の静的初期化で JavaVM と描画ヘルパーのクラス参照を 1 回 Rust に渡し、Rust はそれを保持して静的メソッドを呼ぶ。今の「Activity のメソッドを呼ぶ」経路は廃止 (Activity 破棄後に落ちるため)
    • Kotlin から呼ぶ Rust の入口 (JNI の静的エントリ) は手元側に置き、呼び出し時に渡された環境だけで完結する。release は panic で即終了する設定なので、入口から到達する自前コードは Result 一本で書き、落ちる書き方 (unwrap / expect / 添字) をモジュール単位の lint で禁じる。依存クレート内の panic は防げない (許容する。ワーカー起因でアプリが落ちる頻度を見てから見直す)。この入口は Tauri コマンドではないので、既存の入口規約と同じ意味のマーカーを付け lint を拡張する
    • ワーカーの実行時間は短く、push の配送時の前面猶予は 5 秒なので、本文だけ先に出し、画像を取れたら同じ (タグ, ID) で更新する

    4.2 ワーカーを Rust に移す (常に走る安全網)

    ワーカー (Kotlin) は前面フラグ・OS 言語・設定ディレクトリを渡して Rust の入口を呼ぶだけになる。Rust 側:

    1. 前面フラグが立っていれば何もしない。通知の権限が無ければ何もしない
    2. keychain の Android ストアを application Context を明示で渡して自前で開く (Activity に紐づくネイティブ文脈には触らない。Activity 由来の既定ストアは Activity を抱えているので共有しない)。読めなければ (再起動後の未ロック、Keystore の一時エラー) 何も書かずに終わる
    3. 口座一覧は DB を読み取り専用で開いて取る (口座表の固定列だけ、migration は走らせない。開けなければ何も書かずに終わる)。口座一覧の写しファイルは廃止
    4. 口座ごとに i を 1 回取る。通知は先に未読数を見る: 0 ならその口座の通知を全部消し処理位置を最新に進めて終わり。n > 0 なら処理位置以降を「既読にしない」で取り、新しい方から高々 n 件だけ描画し、処理位置を最新に進める (本家に通知ごとの既読フラグが無いため、他端末で読んだ分を出さないにはこれしかない)
    5. メッセージは i の未読有無が true のときだけ履歴 (ユーザー / ルーム) を取り、未読の会話の最新メッセージのうち処理位置より新しいものを描画し、処理位置を進める。false なら消す
    6. 口座が push 主体でも走る (保険と生存確認): 未読なのに push で出していない通知があれば出す。同じ通知が push で届かないままワーカーが連続して数回空振りしたら push を諦めてワーカー主体に戻し、状態行と受信トレイに 1 件残す。復号できた push をその口座で受けたら push 主体に戻す (受信そのものが生存の証明)。Doze で push とワーカーの順序が入れ替わるだけでは落とさない
    • 処理位置は口座ごと・種別ごとに 1 つ。処理した側が前面でも背面でも進める (出した / 可視で抑止した / 出さない種別も含む。push で捨てた分だけ進めない)。同一プロセスなので排他し、ホストの ID 順で単調増加のみ受け付ける。置き場は設定ディレクトリではなく端末の持ち物 (画像キャッシュと同じ扱い)
    • これで「利用者が払った通知をワーカーが戻す」「背面で両経路が生きていた短い時間に二重に出る」「既読済みを出す」が全部消え、Kotlin のトークン復号コードと keyring 内部形式への依存も消える
    • ワーカーの周期と Doze 中の遅延は OS の制約のまま。電池低下時の停止は NoteDeck 自身の制約なので外す。利用者向けには仕組みをそのまま書き、周期は定数から文言に補間する
    • keychain の口は上流に出す: OS キーチェーン用クレートに Context を受ける constructor を足す PR (生成時にしか使っていないので差分は小さい)。入るまでは fork を patch で指す (通らなくても fork で進める)
    • PoC で確かめること (段階 0): 新プロセスでワーカーが走った後にアプリを開いて落ちない / ワーカー実行中にアプリを開いても落ちない / Activity 破棄 → ワーカー → Activity 再生成 / ワーカーが先に既定ストアを立てた後に Activity が開いても同じ鍵に届く / ライブラリの読み込み時間 / 再起動後の未ロックで静かに終わる / アプリ生存中に DB を読み取り専用で開けて書込中でも落ちない

    4.3 データと保存先

    • push の三つ組の写しは keychain (サービスを分けて) に置く。設定ディレクトリに置くと設定バックアップに秘密が混ざる。口座の削除 / ログアウトは今その口座のトークンしか消さないので、解除が通った時点で三つ組も消す
    • 端末外バックアップから除外する (新旧 2 方式の両方): 処理位置・keyring の領域・push の鍵領域
    • ログ方針: 通知の中身 / エンドポイント / 三つ組 / username@host をログに出さない

    4.4 通知チャネル

    • 作成と改名は Kotlin の静的ヘルパー 1 箇所に統一し、言語変更は Rust から伝える
    • 種別群で分け、名前は用語集に合わせる (OS の通知設定で単独で読まれる文字列):
    チャネル 種別 既定の重要度
    メンション・返信 mention / reply / quote 高 (heads-up)
    リアクション・リノート reaction / renote 標準
    フォロー follow / receiveFollowRequest / followRequestAccepted 標準
    投稿の通知 note 標準
    アカウント login / app 標準
    その他 achievementEarned / pollEnded / test 標準
    メッセージ newChatMessage 高 (heads-up)
    • 根拠: feat: 通知の制御設定 (ON/OFF・種別・DND・音量) #747 の「通知設定は OS に委ねる」を実現するには OS 側に委ねられる粒度が要る。サーバー側の受信設定はアプリ内表示ごと消す設定なので代替にならない。login は本人確認の性格があり、多発する実績と同じチャネルだと実績を切った人がログイン警告も失う。種別 1 つずつには分けない。チャネル ID は固定し、旧チャネルは更新時に削除する。グループサマリは作らない

    4.5 利用者に見せるもの

    • 専用の設定画面・トグルは作らない
    • 通知の権限要求は起動時ではなく、オンボーディング (feat: 初回起動のチュートリアル、初期プロファイルに基本カラムセット #680) の最初の口座追加の直後に組み込む。全 Android 共通で、権限ダイアログの前に説明 1 画面: 「開いている間はすぐ届きます。閉じている間は一定時間ごとに確認します (省電力中は遅れることがあります)」(周期は定数から補間)。拒否が重なって OS がダイアログを出さなくなった後は、状態行から OS の通知設定へ飛べるようにする
    • 状態行: 定期確認 / push 有効 / プッシュ通知アプリ未導入 / 本家での登録待ち / プッシュ通知アプリで操作が必要 / サーバー未対応 / 権限なし / 再ログインが要る。タップで docs の説明 (何のための再ログインか、を含む) に飛ぶ
    • push (§5) が出るまでは、利用者向けの文書 (Play / F-Droid の説明文、docs、説明 1 画面) に UnifiedPush / ntfy の語を出さない。約束するのは「開いている間は即時、閉じている間は定期的に確認」だけ。push が出た後も、ストアの説明文では「プッシュ通知アプリと本家での登録が要る」と書き、無条件の即時通知を約束しない
    • docs に「閉じている間の即時通知が要るなら、本家 Web をホーム画面に追加すると Web Push が届く (NoteDeck の通知と二重になる)」を書く
    • プライバシーポリシーの Web Push の記述を実態に直す (VAPID 鍵は端末に無い)。段階 A

    5. push (口座ごとのオプトイン、外部 distributor 限定)

    §3 / §4 の上に「背面経路がもう 1 本増え、ワーカーは安全網に下がる」形で載せる。描画・同一性・既読・出し分けは共通で、変える箇所は以下だけ。

    購読登録は利用者が本家 Web で行う (Aria 方式)

    • アプリが UnifiedPush のエンドポイントと三つ組を用意し、sw/register を呼ぶ AiScript のコード (口座名の照合つき) を 1 画面に出す: 「コードをコピー」「本家の Scratchpad を開く」「応答を貼る」の 3 操作。Scratchpad の Mk:api は Web セッションのネイティブトークンで呼ばれるので、本家を変えずにネイティブ限定を通る
    • 応答 JSON を貼ったら、アプリが userId と endpoint が自分のものと一致するか・key が null でないかを検証してから保存する。一致しなければ保存せず理由を出す (別口座でログインしている / サーバーが Web Push 未設定)
    • 再登録が要るとき (エンドポイントや鍵が変わった、サーバーの鍵が変わった、登録が消えた) は同じ画面をもう一度。状態行に「本家での登録待ち」を出し、タップでその画面へ。自動で再登録はできない (トークンが無い) ので、再登録の頻度を下げる設計にする (distributor を変えない限りエンドポイントは安定)
    • 解除は sw/unregister をアプリから直接呼ぶ (認証任意・三つ組必須)。Aria も同じ
    • この手作業は口座ごとに 1 回。利用者に求めるのは「プッシュ通知アプリを入れる」と「本家で 1 回コードを実行する」の 2 つで、どちらもオプトイン。ストアの説明文で無条件の即時通知を約束しない (§4.5)

    確認済みの事実 (本家、2026-10-12 時点): push の型は notification / unreadAntennaNote / readAllNotifications / newChatMessage。notification は本文が切り詰められノートの投稿者が落ちる (発生元ユーザーは残る)。newChatMessage は切り詰めない。readAllNotifications は購読時に希望した場合だけ届く。メッセージの push は 3 秒以内に既読なら送らない。購読登録の重複判定は (ユーザー, 三つ組) の一致。応答にサーバーの VAPID 公開鍵が返り、サーバーが Web Push 未設定でも登録は成功して鍵が null (失敗では検知できない)。送信先が 410 を返したときだけ購読を削除する。本家の Service Worker は 1 日より古い push を捨てる。sw/unregister は 30 回 / 時のレート制限。

    確認済みの事実 (UnifiedPush connector v3 / 仕様 AND_3): 受信には decrypted フラグがあり、復号できなかった平文も届く。配送時に distributor はアプリを 5 秒間だけ前面相当に上げる。登録失敗の種別は INTERNAL_ERROR / NETWORK / ACTION_REQUIRED / VAPID_REQUIRED。登録解除の通知はアンインストールでは来ない。三つ組 = 購読の endpoint URL + auth + 公開鍵 (p256dh)。

    5.1 受け口: UnifiedPush の connector (v3)。distributor は同梱せず外部に委ねる。1 つなら自動選択、複数なら connector の選択機構に委ねる。ゼロならワーカーのまま。

    • 不採用 (確定): 同梱 FCM distributor (Play 版だけの flavor)。実行時に端末の Play Services を使うため「Google 非依存」にならない。LGPL と AGPL の組み合わせの論点。flavor が増えると CI とテストの面が倍になる。Aria は同梱 FCM を作者運営の中継サーバー (Cloudflare Workers) と組で提供しているが、NoteDeck は自前サーバーを持たない (meta: notecored をサーバーで常駐させ、NoteDeck をリモートクライアントとしても動かせるようにする #1106) ので外部 distributor に限る
    • 謳い文句は「Google 非依存」ではなく 「配送先を利用者が選べる」 (Play 版の ntfy は配送に FCM を使うし、公開の ntfy.sh は個人運営)

    5.2 責務分担

    役割 Kotlin Rust
    distributor への登録 / 解除の実行 ○ 指示
    エンドポイントと鍵の保持 ○ (アプリ専用領域、バックアップ除外) 登録に使った三つ組の写しを口座ごとに keychain に保持 (設定ディレクトリに置くと設定バックアップに秘密が混ざる。解除が通った時点で消す)
    購読登録 / 解除 / 再登録、VAPID 公開鍵の取得と更新検知 × ○
    push の受信 ○ ×
    振り分け・文言・画像・deep link の組み立て × ○ (§4.1、DB を読んで (ホスト, ユーザー ID) で口座を引く)
    描画 ○ ×

    Kotlin → Rust の経路は JS を経由しない。入口は「ワーカーの実行」「エンドポイントが届いた (合図だけ、秘密は載せない)」「push が届いた (復号済み payload)」。独自の Tauri plugin は作らない: 必要な経路はこれで足り、plugin は Kotlin ソースの置き場と同期方法 (#755) を変える。

    5.3 購読の単位とライフサイクル

    • distributor への登録はホスト単位 (VAPID 公開鍵がホストごと)。サーバーへの購読と状態は口座単位。届いた payload の (ホスト, ユーザー ID) で口座に振り分ける (グローバル照合だと悪意あるサーバーが他ホストの ID を名乗れる)
    • 口座の背面経路はワーカー主体か push 主体のどちらか。push 主体になるのは購読登録が成功し、応答の鍵が null でなく、以後登録失敗 / 一時不可 / ワーカーの生存確認を受けていない間だけ。戻った口座は、復号できた push を受ければ push 主体に戻り、それ以外は利用者が本家で登録し直すまでワーカー主体
    • 起動時 (ホストごと): VAPID 公開鍵を取る → Kotlin に「この鍵で登録済みにしておけ」(冪等) → エンドポイントがあり、保存済みの登録 (三つ組 + サーバーの鍵) と一致すれば push 主体のまま。エンドポイントか鍵が変わっていれば push 主体を外し、状態行に「本家での登録待ち」を出す (自動登録はできない)。古い三つ組は sw/unregister で消す
    • 起動時に distributor の存在を確認し、無ければ保存したエンドポイントを捨ててワーカー主体に戻す
    • 既読通知を希望して登録する (届くのは「全部既読にした」1 回分)
    • 口座の削除 / ログアウト: トークンを捨てる前に認証付きで解除 (三つ組つき)。通ったら三つ組を消す。失敗しても削除 / ログアウトは進める。失敗した三つ組は次回起動で未認証解除 (トークンはもう無い。同ホストの他口座の購読も消えるので、残りの口座は「本家での登録待ち」に戻る)
    • トークンが先に失効した (本家側で連携を解除された、既存の認証エラー種別で検知) ときも同じく未認証解除
    • instance の作り直しは未認証解除すら通らないときの最後の手段: connector で解除 → 再登録、未解除の記録を破棄、同ホストの全口座を「本家での登録待ち」に戻す。410 での自動削除は保険であって当てにしない

    5.4 push の中身の扱い

    型 扱い
    notification 正規化 → §4.1 で描画。切り詰めで落ちた情報は取り直さない
    newChatMessage §4.1 で描画
    readAllNotifications その口座の通知を全部消す。前面でも処理する
    unreadAntennaNote 捨てる (アンテナの OS 通知は今も出していない)
    復号できなかったもの 無条件で捨てる。 エンドポイント URL を知る者は誰でも平文を POST できるので、復号できた物だけが本物
    (ホスト, ユーザー ID) がトークンを持つ口座に無い 捨てる

    5.5 セキュリティ

    • エンドポイント URL は公開情報として扱う。秘密は auth と connector が持つ復号鍵だけ
    • Rust に渡すのは合図と復号済み payload だけ。WebView (プラグイン / AiScript) から秘密が読める経路を作らない
    • 受信サービスは非公開、別プロセスにしない (前面フラグと背景ランタイムの共有は同一プロセス前提)
    • deep link のホストは payload ではなく instance の対応表から取る。ID は英数字のみ
    • 平文の自前 ntfy は本家が拒む (https 必須)

    6. 段階

    段階 内容 完了の判定 依存
    0 PoC (§4.2 の項目) + keychain の fork patch 全部通る。fork でも技術的に不可能なときだけ §4.2 を Kotlin ポーリングのまま残す判断に戻る (その場合、口座一覧の写しファイルは残る) なし
    A 既読の変更 (§3.2、write:notifications の追加・再認可案内の最小形・バッジの未読数の修正を含む) → ストリーミングの変更 (§3.1)、deep link に口座とメッセージ会話 (§3.4)、プライバシーポリシー 通知カラムの無い口座の OS 通知が (抑止条件の外で) 出る。取得しただけでは既読にならず、通知カラムを見ると既読になる なし
    B 挙動不変: 組み立ての純関数化と正規化経由の統一、背景ランタイムの共有、描画経路の Activity 非依存化、lint (§4.1) 純関数が単体テストで固定され、見た目が変わらない。Activity を閉じた後の通知で落ちない A
    B' 挙動変更: (タグ, ID) と既読消去 (§3.4)、チャネルの統一 + 旧チャネル削除 + 重要度 (§4.4)、前面と可視カラムの出し分け (§3.3)、投稿通知とメッセージの OS 通知 ストリーミング経路の挙動が §3.3 の表どおり B
    C ワーカーの Rust 移植 (§4.2)、口座一覧の写しファイル廃止、バックアップ除外、ログ方針、電池制約の撤廃、Kotlin からトークン復号コードの削除 Kotlin に keyring の語が残らない。閉じている間の通知とメッセージが出る 0、B'
    D 権限要求のタイミング、状態行、docs、Play の説明文 (§4.5) 文言が i18n 経由 C
    E keychain クレートの「Context を明示で渡す口」: 上流に同じ目的の PR (open-source-cooperative/android-native-keyring-store#50、2026-10-20、メンテナ発) が既にあるので自前の PR は作らず、その branch を patch で指す。リリースされたら外す patch で Android のコンパイルが通る なし (A〜D と並行)
    F push (§5): 登録画面、受信サービス、購読のライフサイクル、状態行の push 項目 手動検証項目が通る C、D

    検証 (手動):

    • A: 通知カラムを閉じた口座への通知が OS 通知になる / 取得しただけでは本家の未読バッジが消えない / 通知カラムを見ると消える / 可視中の到着で消える / ポーリングモードで全口座が取得しても既読にならない / 起動時に WS が一瞬開いて閉じない
    • B: 戻るキーで閉じた後 (プロセス生存) の通知で落ちない
    • B': 前面で通知カラムが可視なら出ない、他のカラムなら出る (デスクトップも同じ) / 同じ通知を 2 回受けても 1 件 / 同ホスト複数口座で正しい口座として開く、不一致なら開かない / 旧チャネルが消え旧通知が掃除される / アプリの言語と OS の言語が違っても同じ文言
    • F: 本家での登録画面 (別口座でログイン中 / Web Push 未設定サーバーで保存しない) / 配送アプリの有無 / 前面・背面・終了・凍結 / 前面で未接続のときに push が出る / 複数口座 / 同ホスト複数口座 / ログアウト (未認証解除と残りの「登録待ち」) / 他端末の既読で消える / 1 日より古い再配送を捨てる / 復号できない平文を捨てる / Doze で順序が入れ替わっても落ちない / 受信で戻る / ACTION_REQUIRED の状態行
    • C: アプリ終了中に通知とメッセージが届く (ワーカーの周期 + Doze の遅れ) / 前面で出した通知が背面のワーカーで戻らない / 消した通知が戻らない / 他端末で既読にしたら消え、既読済みは出ない / 再起動後の未ロックで落ちず処理位置が進まない / 権限拒否時に取得しない / cold start の deep link / 解凍直後の数秒 / アプリ生存中のワーカーで落ちない

    7. 決めたこと (2026-10-22)

    # 判断 決定 理由
    A 本家への提案 出さない (2026-10-24 に決定) 本家の変更を期待しない方針を徹底する。§5 の手作業は本家を変えずに成立する手口なのでそのまま
    B 閉じている間の経路 ワーカーを Rust に移す 不変条件 1 (Kotlin が認証情報に触らない) と描画の 1 実装化のため
    C 前面での OS 通知 (Android) 通知カラムが可視のときだけ抑止 一律抑止だと音もバッジも通知カラム mount 中しか鳴らず気づけない
    D チャネルの括り §4.4 の表 (login は「アカウント」) 多発する実績と同じチャネルだとログイン警告ごと切られる
    E 既読のタイミング 通知カラムを見たときだけ 見ていないものを既読にしない。通知カラムを置かない人は本家 Web のバッジが残るが、それは「見ていない」の正しい表示
    F 権限ダイアログ前の説明 1 画面 #680 のオンボーディングに組み込む (全 Android) 通知のことを考えている瞬間に 1 度だけ言う
    G keychain クレートの上流 PR (#50) がリリースされない場合 その branch を patch で指し続ける 差分が小さく、不変条件 1 の価値が大きい。2026-10-24 に確認: 自前の PR は不要
    H デスクトップの前面抑止 Android と同じ「その口座の通知カラムが可視なら抑止、それ以外は出す」 通知カラムを置いていない口座に気づく手段が無い
    J ワーカーの電池制約 外す 周期ごとに数本の REST に見合わない
    K チャネルの既定の重要度 メンション・返信 / メッセージを高、他は標準 人からの呼びかけだけ heads-up
    L 依存クレート内の panic 許容 (即終了) ワーカー起因でアプリが落ちる頻度を見てから見直す
    M push の登録手順を利用者に求めるか 求める (口座ごとに本家で 1 回) オプトインで、求めない人には何も変わらない

    8. 2026-07-25 の設計からの変更点

    1. ブロッカー (本家のネイティブトークン限定) を前提に据え、本家の変更を期待しない。push は「登録だけ利用者が本家 Web の Scratchpad で行う」(Aria 方式) で本家を変えずに成立させ、口座ごとのオプトインとして計画に残す
    2. ストリーミングを「全口座を Rust の口座セッションが張る」に変え (fix: ポーリングモードと後から追加したアカウントでチャットの履歴一覧に新着が出ない #1223 / リアルタイムモードの永続設定が再起動後にバックエンドへ適用されない #1004 の判断の撤回)、既読を「見たときだけ」に変えた。既読の手段は既存トークンで通るもの
    3. ポーリングを廃止ではなく Rust に移し、背面経路の本体にした。未読数を先に見て新しい分だけ出す。メッセージも拾う
    4. 抑止の根拠を「接続中フラグ」から「前面 + 可視カラム」に変えた。背面経路の状態は口座単位
    5. 組み立てを notecore の純関数と手元側のローカル化に分け、背景ランタイムはプロセスで 1 つ、描画経路は Activity 非依存、入口は Result 一本
    6. 通知の同一性を (タグ, ID) にし、消す契機を既読と揃え、処理位置は処理した側が進める
    7. deep link に口座を載せ (不一致なら開かない)、メッセージ会話のルートを足す
    8. ワーカーは Activity に紐づくネイティブ文脈に触らず、keychain に Context を明示で渡す (上流 PR)
    9. Kotlin → Rust は JS を経由しない入口。三つ組の写しは keychain
    10. 解除は best effort、失敗後は未認証解除、作り直しは最後の手段。自動の再登録は無い (登録待ちの状態行で利用者に戻す)
    11. 復号できなかった push を捨てる
    12. チャネルは種別群、名前は用語集準拠、投稿通知とメッセージを新たに出す
    13. push が出るまで利用者向け文書に UnifiedPush の語を出さない。出た後も無条件の即時通知は約束しない
  11. 24 remaining items

  12. hitalin commented on Oct 24, 2026

    @hitalin
    CollaboratorAuthor

    進捗: 段階 C の残りと段階 F (push) — develop 済み (2026-10-24、未リリース・実機未確認)

    段階 C の残り

    • v1.93.0 に入った取得のバグを直した: 閉じている間の巡回が、処理位置より新しい通知を「古い順」で取っていた。本家の通知の取得は、起点だけを渡すと古い方から返す。処理位置が古いと、既読の古い分を取って新しい未読を取りこぼしていた。設計 (§4.2) どおり、新しい方から未読数ぶんを取り、処理位置で絞る形に直した
    • 端末外バックアップから外した (§4.3): トークンの暗号文 (鍵は端末の Keystore にありバックアップされないので、復元しても読めない) と、OS 通知の処理位置。push を足したので、push のエンドポイントと鍵も外した。旧方式 (Android 11 以下) と新方式の両方
    • 閉じている間の通知にも画像を付けた (§4.1): 本文を先に出し、アバターとリアクションの絵文字が取れたら同じ通知を描き直す (音は鳴り直さない)

    段階 F: push (口座ごとのオプトイン)

    利用者の操作は、アカウントメニューの「プッシュ通知」から 1 画面で完結する。

    1. 「登録を始める」で、プッシュ通知アプリ (UnifiedPush) にこのサーバー用の送り先を作ってもらう。アプリが 1 つなら自動で選び、複数で既定が無ければ OS の選択画面が出る
    2. 表示されたコードをコピーし、Web UI の Scratchpad で実行する。コードは先頭で口座名を照合し、別の口座でログインしていれば登録せずに止まる
    3. 表示された応答を貼る。NoteDeck は「この口座の登録か」「今の送り先か」「サーバーが Web Push を設定しているか」を確かめてから使い始める
    • 状態行は「定期的に確認 / プッシュ通知 / 登録し直しが要る / プッシュ通知アプリで操作が要る」を出し分ける。push を使わない人には何も変わらない
    • 受け取り: 復号できたものだけを扱う。どの口座宛てかは「届いたサーバー」と「中身のユーザー ID」の組で引く。描画・同一性・処理位置・可視カラムの抑止はストリーミング / 巡回と共通。全部既読の合図で通知を消す。1 日より古い再配送は捨てる。本家が push の字数制限で落とすノートの投稿者は、組み立てに使わないので取り直さない
    • やめる / 口座の削除 / ログアウト: トークンがあるうちに、サーバーへ購読の解除を送る。失敗しても削除やログアウトは進める
    • 登録の情報 (送り先と鍵) は端末の中に置き、バックアップに含めない
    • UnifiedPush の受け口のライブラリは、Tauri が生成する Android プロジェクトの Kotlin の版に合わせて 3.0 系に固定した。新しい系列は Kotlin の標準ライブラリを新しい版に上げ、アプリ全体がコンパイルできなくなる
    • 利用者向けの説明 (ガイドの日英)、プライバシーポリシー、用語集 (「プッシュ通知アプリ」) を更新した。無条件の即時通知は約束していない

    確かめたこと / まだのこと

    • 確かめた: 登録コードを本家のフロントと同じ版の AiScript で実際に実行するテスト (同じ口座なら登録を呼んで応答を出す / 別の口座なら止まる)。Android の debug APK のビルドと、マニフェストに受け口・バックアップ除外が入ること。各純関数の単体テスト
    • 実機は未確認。確認項目は §6 の F の一覧のとおり
    • 後回し (計画どおりの残り): 巡回の生存確認による push ⇄ 巡回の自動切替、解除に失敗した購読の次回起動での未認証解除、配送アプリ側の登録の作り直し、起動時のサーバーの鍵の変更検知 (今は状態を見たときに送り先と鍵の一致で判定)
  13. added a commit that references this issue on Oct 24, 2026
  14. hitalin commented on Oct 24, 2026

    @hitalin
    CollaboratorAuthor

    段階 F の登録手順を作り直す (仕様、2026-10-24)

    v1.94.0 で出した登録手順 (コードに送り先と鍵を埋め込み、応答の JSON を貼り戻す) を置き換える。4 視点 (セキュリティ / 本家と Android の事実 / 利用者の手間 / 失敗時の振る舞い) の敵対的レビューを 4 巡 + 最終確認で収束させた。§5 の「購読登録は利用者が本家 Web で行う」はそのまま、手順と仕組みを作り直す。

    動かせない前提

    • 本家の sw/register は Web UI にサインインしたときの本人用トークンでしか通らない。NoteDeck のトークン、NoteDeck の AiScript、本家の Play (資格なしで呼ぶ)、本家 Web のプラグイン (プラグイン用トークン) はどれも通らない
    • 本家の Scratchpad は本人用トークンで動くが、コードは端末の保存からしか読まず、URL では渡せない
    • → 口座ごとに 1 回、利用者が Scratchpad で ▶ を押す手順は消せない。それ以外を自動化し、その 1 回を最小・安全・失敗しにくくする

    利用者の操作

    • 端末ごとに 1 回: プッシュ通知アプリ (UnifiedPush 対応) を入れる。無ければ窓が入手先を直接開き、戻ったら検出し直して続ける
    • サーバーごとに 1 回 (同じサーバーの口座はまとめて):
      1. アカウントメニューの状態行「閉じている間の通知: 定期確認 (最大 15 分) ›」からサーバー単位の窓を開き「有効にする」
      2. 「コピーしてスクラッチパッドを開く」
      3. ブラウザで、その口座でログインしていることを確かめ、スクラッチパッドの中身を全部選んで貼り付け (空なら長押しで貼り付け)、コードのすぐ下の ▶
      4. 「@A を有効にしました。続けて @b に切り替えて ▶ するか、NoteDeck に戻ってください」。数分以内に「@A のプッシュ通知が届くようになりました」が届き、押すと NoteDeck に戻る
    • 応答の貼り戻しは無い。待たせない (届くまで数分かかることがあり、閉じてよい)

    秘密をコードに入れない (レジストリ + 合言葉)

    • NoteDeck は自分のトークンで本家のレジストリ (口座ごとのキー値の保存) に送り先と鍵を書く。アプリのトークンで書いた項目はそのトークン専用の領域に入り、他のアプリは読めない。本人用トークン (Scratchpad) は領域を指定して読める
    • 読む対象は推測できない合言葉で絞る。コードは合言葉と対象の口座の一覧だけが変わる定型文で、全文を docs に公開する
    • 合言葉はサーバー単位の試行ごとに作る。作り直すのは「やり直す」・登録し直し・再ログインのときだけ。未実行の間は同じコードを渡し直す
    • コードは登録に成功したら、その項目を秘密の無い「済み」の印で上書きし、テスト通知を送る。NoteDeck はその印を読んで「未実行」と「到着待ち」を見分ける
    • 前回の試行の項目は消さずに「取り消し済み」の印にする。失効したトークンの領域に残った登録待ちも、コードが毎回取り消し済みにする (秘密の消し残しの掃除)

    コードの判定 (第三者の割り込みを止める)

    他のアプリも自分の領域に偽の登録待ちを置けるので、登録に進むのは条件がぴったり合うときだけ。

    生きている領域の登録待ち 印 (済み / 取り消し済み) 動き
    ちょうど 1 件 0 件 登録する (進むのはこの行だけ)
    0 件 取り消し済みあり 古いコード。出力欄に 1 行書いて終わる (止めないので後ろの新しいコードは動く)
    0 件 済みだけ 「登録済みです。NoteDeck で届くのを待っています」
    0 件 0 件 「登録待ちがありません。@A か @b に切り替えて ▶」
    それ以外 「確かめられませんでした。NoteDeck で『やり直す』を押してください」
    • 対象の口座でなければ、何もせずに案内して終わる。コード全体をブロックで包み、古いコードが前に残っていても衝突しない

    配送アプリへの登録は口座ごと・世代ごと

    • 口座ごと・世代ごとに分けて登録する。新しい世代の送り先を受け取ってから古い世代を外す (最後の登録が消えて、選んだ配送アプリまで消える瞬間を作らない)。送り先と鍵は OS のキーチェーンに口座 + 世代で持ち、解除が通るまで残す
    • 作り直す対象は、使っていない・登録し直しが要る・未実行の口座だけ (有効・到着待ちは触らない)
    • 配送アプリの画面で見分けられるよう、登録に口座名を添える

    確定 (届いた push で)

    • 登録待ちの口座の今の世代に、復号できて宛先のユーザーが一致する push が届いたら「有効」。種別を問わない。前面での抑止・処理位置・描画より前に記録する。時刻は比べない (鍵が新しいので古い push は復号できない)
    • 確定に使ったテスト通知は「@A のプッシュ通知が届くようになりました」として出し、処理位置は進めない。以後、巡回とストリーミングはその通知を出さない
    • 到着待ちを最初に見てから 3 分過ぎても来なければ、NoteDeck が自分のトークンで 1 回だけテスト通知を送る。それでも来なければ、本家から配送アプリのサーバーに届いていない可能性 (自前のサーバーや LAN) を案内する。登録待ちは自動では捨てない

    事前判定と失敗の文言

    • サーバーが Web Push を設定していない: 「このサーバーはプッシュ通知に対応していません。管理者が設定すると使えます。それまでは定期確認 (最大 15 分) で届きます」
    • 配送アプリの送り先が https でない: 「プッシュ通知アプリの設定で https のサーバーに切り替えると使えます」
    • レジストリに書く権限が無い古いトークン: ブラウザに行かせず再ログインを案内
    • 配送アプリが複数で既定が無い: OS の選択、だめなら NoteDeck の一覧で選ばせる

    状態と後始末

    • 口座ごとの状態: 使っていない / 準備中 / 登録待ち (未実行・到着待ち) / 有効 / 登録し直しが要る / 解除待ち。遷移は純関数で表に固定し、実機無しで検証する
    • 状態は端末の中 (端末外バックアップから除外、秘密は置かない)。解除・印の上書き・登録の取り外しといった後始末は別のキューで持ち、口座を消しても残る。起動時に状態・キーチェーン・配送アプリの登録を突き合わせる
    • 解除: その口座のトークンで解除 (トークンが無ければ未認証。送り先と鍵が口座ごとに一意なので他の口座を巻き込まない)。失敗は解除待ちで残して再試行。解除待ち・状態の無い登録・古い世代宛ての push は捨てる
    • 登録し直しが要る: 送り先か鍵の変化、サーバーの鍵の変化 / Web Push の停止、巡回が「有効なのに push で来ていない未読」を連続で見つけたとき。届いた push は表示する。理由が「未着の推定」なら、届いた時点で有効に戻す。起動時と復帰時にアプリの通知 (受信トレイ) に 1 件出す

    v1.94.0 からの移行

    • v1.94.0 はサーバー単位で登録していて、同じサーバーの口座が送り先と鍵を共有していた (片方の操作がもう片方の push を壊す、解除に失敗した購読の通知が出続ける)。サーバーの全口座をそれぞれのトークン (ログアウト済みなら未認証) で解除してから登録を外し、「登録し直しが要る」にして受信トレイに出す

    次の段階 (別 issue)

    • ログインの認可の戻り先を NoteDeck にして自動で戻し、そこで準備して Scratchpad を開く (口座の追加と push の登録を 1 回のブラウザ訪問にまとめる)

    採らない案

    • NoteDeck の AiScript / 本家の Play / 本家 Web のプラグイン: 本人用トークンで呼べない
    • アプリ内のブラウザでログインして自動実行: 実質パスワードログイン
    • 同梱 FCM + 中継サーバー: 自前サーバーと Google に依存しない方針
    • 送り先と鍵をコードに埋める (v1.94.0): 秘密がクリップボード・Scratchpad の保存・誤って貼った別サーバーに残る。「未実行」と「届かない」を見分けられない
    • 応答の貼り戻し (v1.94.0): 手間が 1 往復多く、届くかは確かめていない
    • 確定後や次の試行で前の項目を消す: 合言葉を知る第三者の偽の登録待ちが通る
    • 合言葉を端末ごとに固定: 再ログインで止まり続け、漏れる期間が延びる
    • 「前のコードの後ろに貼る」を主な案内にする: Android の貼り付けは選択中の単語を置き換える
    • 時刻で確定を絞る: 端末と本家の時計のずれで永遠に確定しない
    • 作り直しのたびに口座の登録を外す: 選んだ配送アプリまで消える
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

[Feat] NotificationNotification related issue✨FeatureThis adds/improves/enhances a feature🗺RoadmapLong-term issue that may happen in far future

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions