記録するものは「薬を飲んだ時刻」のような健康情報になりうるデータなので、 「端末の外に出さない」「他のアプリから触らせない」を基本方針にしている。
| 項目 | 対策 |
|---|---|
| ネットワーク | INTERNET 権限を宣言していない。通信するコード自体がなく、APK のマニフェストで誰でも確認できる |
| 権限 | 宣言しているのは VIBRATE(ウィジェットを押したときの振動用。インストール時に自動許可される通常権限で、個人情報には触れない)だけ |
| 解析・広告・クラッシュ収集 | 入れていない。依存は AndroidX / Material / Kotlin coroutines のみ |
| クラウドバックアップ | allowBackup="false" に加え、dataExtractionRules / fullBackupContent で全ドメインを除外 |
| 端末間移行 | 同上(allowBackup だけでは一部メーカー端末の移行を止められないため明示的に除外) |
| 絵文字フォント | androidx の EmojiCompat 初期化を外している(Play 開発者サービスへのフォント要求を避けるため) |
| ログ | 記録内容・ボタン名を Log に出していない |
データはアプリ専用領域の SQLite(databases/onebotan.db)にだけ保存される。
Android 10 以降はファイルベース暗号化が必須のため、端末ロック中は OS によって暗号化されている。
アプリ独自の追加暗号化はしていない(ウィジェットがロック解除直後から動く必要があり、
鍵を端末内に置く以上、得られる保護が小さいため)。
| 経路 | 対策 |
|---|---|
| ブロードキャストで記録を偽装 | ButtonWidget / BoardWidget / CalendarWidget は exported="false"。他アプリからは届かない(CalendarWidget は描くだけで記録しない) |
PendingIntent の横取り・書き換え |
すべて明示的 Intent + FLAG_IMMUTABLE |
| 別ウィジェットのボタンを押させる | ウィジェットごとに requestCode と data URI(onebotan://widget/<id>/...)を分けている。extras だけで区別すると PendingIntent が共有されてしまうため |
| Intent に tracker id を載せて任意のボタンを押させる | 押下時の記録先は Intent ではなくDB のウィジェット割り当て表 / ボード配置表から引く。ボードは (widgetId, 枠番号) ごとに別の PendingIntent |
| 履歴・編集画面を開かせる | HistoryActivity / EditActivity / CalendarActivity は exported="false"。ウィジェットの「設定」から開くのはアプリ自身 |
| 設定画面を任意の widgetId で開かせる | WidgetSetupActivity / BoardEditActivity は APPWIDGET_CONFIGURE のため exported 必須。起動時に getAppWidgetIds() でそれぞれ自分のウィジェットか検証し、違えば即終了 |
| 日付更新のアラームを悪用 | 深夜 0 時の再描画は自分の非公開レシーバー宛ての明示的・FLAG_IMMUTABLE な PendingIntent。届いても全ウィジェットを描き直すだけで、記録には触れない。AlarmManager.set()(非正確・非ウェイクアップ)なので権限も不要 |
| SQL インジェクション | 全クエリがバインド引数。ユーザー入力を SQL 文字列に連結していない |
ライブラリが追加するコンポーネント:
ProfileInstallReceiver(androidx.profileinstaller): exported だがandroid.permission.DUMP必須のため、adb/シェル以外からは呼べないDYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION(androidx.core):signature保護の内部用権限
- CSV は Storage Access Framework(ファイル保存ダイアログ)で書き出す。ストレージ権限は不要
- ファイル名は
onebotan-<日付>.csv固定。ボタン名(例: 薬の名前)をファイル名に含めない - 表計算ソフトで開かれる前提で、
= + - @などで始まるセルは先頭に'を付けて数式として実行されないようにしている(CSV インジェクション対策) - 読み込みも Storage Access Framework(ファイル選択)で、利用者が選んだファイルだけを読む。20MB を超えるファイルは読まない
- 読み込みは内容を数えて確認を出してから、1 つのトランザクションで追加する。失敗すれば何も変わらない。 記録を消したり上書きしたりはせず、同じ記録(ボタン・時刻・種類が同じ)は二重に追加しない
- 名前は保存時と同じ
Limits.cleanNameを通し、SQL にはバインド引数で渡す
- タップで記録が消えることはない。判定は純粋関数
PressRules.decideにまとめ、タップ列を再生する 単体テストで「記録は増えるか変わらないだけ」を確認している - 例外は 1 秒以内の 2 回目のタップ(指のぶれ)だけで、記録せず弱い振動で知らせる
- 取り消しはアプリの履歴画面でのみ行う(ウィジェットは記録するだけ)
- ホーム画面のウィジェットは、端末を見た人には見える。 既定ではボタンの下に最終記録時刻だけが出る。 メニュー「ウィジェットの文字」で「表示しない」にすれば、マーク(ピクトグラム)以外の情報は出ない。 名前を表示する設定にする場合は、知られたくない内容を名前にしない(例: 「朝のアレ」)
- 端末のロックを破られた場合、アプリを開けば記録は見える
- 署名鍵はリポジトリに入れない(
.gitignoreで*.jks/*.keystore/keystore.propertiesを除外)。 パスワードもどこにも保存しない。tools/release.shはapksignerにその場で入力させる tools/release.shはデバッグ鍵で署名された APK を公開用として出さない(証明書がCN=Android Debugなら停止)- 各リリースには、署名証明書の SHA-256 と APK の SHA-256 を載せる。利用者は
apksigner verify --print-certsで照合できる - ビルド: Gradle Wrapper の配布物は
distributionSha256Sumで固定、gradle-wrapper.jarは公式の チェックサムと一致を確認済み。リリースは R8 で縮小 - CSV もリポジトリに入れない(
*.csvを除外。実データの誤コミット防止) - コミットの作者メールは GitHub の noreply アドレスを使う
- ツールが生成する Python のキャッシュ(実行環境の絶対パスを含む)は
.gitignoreで除外し、 過去の履歴からも取り除いた