diff --git a/CLAUDE.md b/CLAUDE.md index 987c6c0..ccb0636 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ ## 開発進捗 -最新: CameraProvider をテスト可能化(依存注入・撮影フロー特性化テスト・takePicture 分割) +最新: permission_handler 13 系への追従(compileSdk 37・永久拒否判定の修正) ※ 本欄は**最新ステップ 1 行のみ上書き更新**.詳細な進捗履歴(動機・設計判断・失敗パターン)は [docs/PROGRESS.md](docs/PROGRESS.md) に追記する.運用ルールは `.claude/rules/progress-log.md` を参照(該当ファイル編集時に自動ロードされる). ## 必須ルール(コード実装時) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 50e1441..b3d4ec2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -7,7 +7,9 @@ android { namespace = "com.example.smtias_capture" - compileSdk = flutter.compileSdkVersion + // permission_handler_android 14.x が compileSdk 37 を要求するため固定する + // (Flutter 3.47 の既定 flutter.compileSdkVersion は 36). + compileSdk = 37 ndkVersion = flutter.ndkVersion compileOptions { diff --git "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" index f310a1c..d666744 100644 --- "a/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" +++ "b/docs/02_ENV/ENV_01_\346\212\200\350\241\223\343\202\271\343\202\277\343\203\203\343\202\257.md" @@ -75,8 +75,8 @@ | --- | --- | --- | | IDE | VS Code | Flutter / Dart 拡張機能を使用 | | Flutter SDK | 最新 stable | Dart SDK 同梱 | -| Android SDK | Android Studio 経由で導入 | ビルドツール・エミュレータを含む | -| JDK | Flutter 推奨バージョン | Android ビルドに必要 | +| Android SDK | Android Studio 経由で導入 | ビルドツール・エミュレータを含む.`compileSdk` 37(permission_handler 13 系の要求)の SDK Platform が必要 | +| JDK | JDK 17 または 21 | Android ビルドに必要.Android Studio 同梱の Java 25 は Gradle 8.14 非対応のため,別途導入して `flutter config --jdk-dir` で指定する(ENV_02 のトラブルシューティング参照) | | ADB | Android SDK 同梱 | USB 経由の実機デバッグに使用 | | バージョン管理 | Git + GitHub | `gh` CLI で PR 管理 | | CI/CD | 初期段階では導入しない | — | diff --git "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" index 5c9f20c..2f41cb6 100644 --- "a/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" +++ "b/docs/02_ENV/ENV_02_\347\222\260\345\242\203\346\247\213\347\257\211\346\211\213\351\240\206.md" @@ -185,3 +185,26 @@ ```text %USERPROFILE%\scoop\apps\android-studio\current\jbr ``` + +### Gradle ビルドで Java と Gradle の非互換エラーが出る場合 + +`flutter build apk` で「Java/Gradle incompatibility」(例: Java 25 と Gradle 8.14 は非互換)と表示される場合,Android Studio 同梱の JDK が新しすぎる.JDK 21 を別途導入し,Flutter が使う JDK として指定する. + +```powershell +scoop bucket add java +scoop install java/temurin21-jdk +flutter config --jdk-dir="$env:USERPROFILE\scoop\apps\temurin21-jdk\current" +``` + +※ `temurin21-jdk` の導入でユーザー環境変数 `JAVA_HOME` も JDK 21 に設定される.Android Studio は同梱の JDK を使うため影響しない. + +### `Failed to find target with hash string 'android-37'` エラーが出る場合 + +Android SDK Platform 37 は `platforms/android-37.0` という名前でインストールされるが,Android Gradle Plugin は `platforms/android-37` を探すため見つからない([flutter/flutter#192478](https://github.com/flutter/flutter/issues/192478)).SDK の `platforms` に `android-37` から `android-37.0` へのジャンクションを作成する(管理者権限は不要). + +```powershell +$p = "\platforms" +New-Item -ItemType Junction -Path "$p\android-37" -Target "$p\android-37.0" +``` + +※ SDK Platform 37 が未導入の場合は,先に SDK Manager(または `sdkmanager "platforms;android-37.0"`)で導入する. diff --git "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" index 4be528b..7dfbdf3 100644 --- "a/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" +++ "b/docs/04_SPEC/SPEC_01_\347\224\273\351\235\242\346\251\237\350\203\275\344\273\225\346\247\230\346\233\270.md" @@ -162,8 +162,11 @@ ├── 許可 → カメラプレビュー開始 │ └── 拒否 → 「カメラの権限が必要です」メッセージ表示 - + 設定画面への誘導ボタン + + 設定画面への誘導ボタン(永久拒否時のみ) ``` +- 設定画面への誘導ボタンは,権限が永久に拒否された場合(再度ダイアログを出せない状態)にのみ表示する.通常の拒否ではメッセージのみを表示する +- Android では permission_handler_android 14.1.0 以降,権限の状態確認(status)から永久拒否を判定できないため,直近の権限リクエストの結果で判定する + - ストレージ権限は撮影時・一覧表示時にそれぞれ確認する - `permission_handler` パッケージを使用する diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a6bce5d..017102d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -95,3 +95,11 @@ - 動機: `takePicture` が肥大して可読性が低い一方,依存が直接 new されておりテスト不能だった.「テストがない」のではなく「DI していないからテストが書けない」が根因 - 設計判断: 分割の前に (1) DI 化 → (2) fake 注入で現状挙動を固定 → (3) 分割,の順で安全網を先に作る方針を採用.実カメラ初期化(`availableCameras`)に到達せず「初期化済み」状態を作るため,`@visibleForTesting` のテスト専用フックを 1 つだけ production に追加(本番からは未呼び出し・本番挙動は不変) - 発見した既存挙動(後段で要検討・今回は固定のみで未修正): `takePicture` 末尾の `initialize()`(プレビュー再開)が,撮影成功/失敗で設定した `errorMessage` を上書きする.権限が拒否された状態で再初期化に失敗すると,撮影結果のエラー文言が「カメラの権限が必要です」に化ける.実機では権限許可で再初期化が正常進行するため通常は顕在化しないが,研究データ保存の成否表示に関わるため,将来「エラー文言を確定後に再初期化する/再初期化失敗で撮影成否メッセージを潰さない」設計を検討する価値あり + +## permission_handler 13 系への追従(Dependabot PR #23 の取り込み) + +- 状況: permission_handler を 13 系に上げ,Android の `compileSdk` を 37 に固定.`PermissionService` の永久拒否判定を新しい挙動に追従させた +- 動機: permission_handler_android 14.x が `compileSdk` 37 を必須とするメジャー更新.事前分析では「コード修正不要」としていたが,14.1.0 で Android の `Permission.status` が `permanentlyDenied` を返さなくなっており,そのままでは永久拒否時の設定画面誘導が出なくなる(事前分析の見落とし) +- 設計判断: 永久拒否は直近の `request()` の結果で判定し,未 request 時のみ従来どおり status で判定する.公開インタフェースを変えないことで `CameraProvider` とその fake に波及させない +- 失敗パターン(ビルド環境): ① Android Studio 同梱の Java 25 は Gradle 8.14 非対応 → JDK 21 を別途導入し `flutter config --jdk-dir` で指定 / ② SDK Platform 37 が `android-37.0` 名でインストールされ AGP が `android-37` を見つけられない → `android-37` へのジャンクションで回避(手順は ENV_02 のトラブルシューティング) +- 未実装・後段: AGP 8.11.1 と Kotlin 2.2.20 は Flutter のサポート終了予告の警告が出ている(AGP 9.0.1 以上・Kotlin 2.3.20 以上を要求).Flutter 3.47 の migrator が `analysis_options.yaml`・`android/gradle.properties`・`windows/flutter/generated_plugin_registrant.cc` を毎回書き換えるため,取り込むか別途判断が必要 diff --git a/lib/services/permission_service.dart b/lib/services/permission_service.dart index 65a4072..9ec5f25 100644 --- a/lib/services/permission_service.dart +++ b/lib/services/permission_service.dart @@ -2,9 +2,17 @@ /// カメラ・ストレージのパーミッション確認・要求を行うサービス. class PermissionService { + /// 直近の [requestCamera] で得たカメラ権限の状態. + /// + /// permission_handler_android 14.1.0 以降,Android では + /// `Permission.status` が `permanentlyDenied` を返さなくなり, + /// `request()` の結果でのみ永久拒否を判定できるため保持する. + PermissionStatus? _lastCameraStatus; + /// カメラ権限を確認し,未許可なら要求する. Future requestCamera() async { final status = await Permission.camera.request(); + _lastCameraStatus = status; return status.isGranted; } @@ -15,7 +23,14 @@ } /// カメラ権限が永久に拒否されているかを返す. + /// + /// [requestCamera] 実行後はその結果で判定する(Android では + /// `Permission.status` から永久拒否を判定できないため). Future isCameraPermanentlyDenied() async { + final lastStatus = _lastCameraStatus; + if (lastStatus != null) { + return lastStatus.isPermanentlyDenied; + } return await Permission.camera.isPermanentlyDenied; } diff --git a/pubspec.lock b/pubspec.lock index 04d9dea..ffb1e7a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -252,50 +252,50 @@ dependency: "direct main" description: name: permission_handler - sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + sha256: e6bcecadfe40df6b888141f23cbaf6ba6c3da49fb33f6ff887d060dd48544fbd url: "https://pub.dev" source: hosted - version: "12.0.1" + version: "13.0.2" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + sha256: b1ce660f4d0dcaffdf2605a19544c129fb5adda4e6fd039992ae356c4e437039 url: "https://pub.dev" source: hosted - version: "13.0.1" + version: "14.1.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + sha256: "066bbbc60031b63b916a3871e7bb4b6a8c5e7cec8146dafec53d1da304eff0a5" url: "https://pub.dev" source: hosted - version: "9.4.7" + version: "9.6.2" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" url: "https://pub.dev" source: hosted - version: "0.1.3+5" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.4.1" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.2" platform: dependency: transitive description: @@ -525,6 +525,14 @@ url: "https://pub.dev" source: hosted version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" sdks: - dart: ">=3.11.4 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index e68a4a0..f5c8782 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,7 +37,7 @@ provider: ^6.1.5+1 screen_brightness: ^2.1.11 camera: ^0.12.1 - permission_handler: ^12.0.1 + permission_handler: ^13.0.1 shared_preferences: ^2.2.0 dev_dependencies: diff --git a/test/providers/camera_provider_test.dart b/test/providers/camera_provider_test.dart index d4fdbd2..c75dd56 100644 --- a/test/providers/camera_provider_test.dart +++ b/test/providers/camera_provider_test.dart @@ -512,6 +512,17 @@ expect(provider.permissionPermanentlyDenied, isTrue); }); + + test('権限拒否が永久でないとき permissionPermanentlyDenied は false である', () async { + f.permission.cameraGranted = false; + f.permission.cameraPermanentlyDenied = false; + final provider = _makeProvider(f); + + await provider.initialize(); + + expect(provider.permissionDenied, isTrue); + expect(provider.permissionPermanentlyDenied, isFalse); + }); }); group('CameraProvider.takePicture 保存フロー(初期化済み)', () { diff --git a/test/services/permission_service_test.dart b/test/services/permission_service_test.dart new file mode 100644 index 0000000..63318ec --- /dev/null +++ b/test/services/permission_service_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'package:smtias_capture/services/permission_service.dart'; + +/// permission_handler が使うプラットフォームチャネル名. +const _channel = MethodChannel('flutter.baseflow.com/permissions/methods'); + +/// プラットフォームチャネルの応答を差し替えるテスト用のフェイク. +/// +/// `checkPermissionStatus` には [checkStatus] を, +/// `requestPermissions` には [requestStatus] を返す. +class _FakePermissionPlatform { + PermissionStatus checkStatus = PermissionStatus.denied; + PermissionStatus requestStatus = PermissionStatus.denied; + + final List calls = []; + + Future handle(MethodCall call) async { + calls.add(call.method); + switch (call.method) { + case 'checkPermissionStatus': + return checkStatus.index; + case 'requestPermissions': + final permissions = (call.arguments as List).cast(); + return {for (final p in permissions) p: requestStatus.index}; + case 'openAppSettings': + return true; + } + return null; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakePermissionPlatform platform; + + setUp(() { + platform = _FakePermissionPlatform(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, platform.handle); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + }); + + group('PermissionService', () { + group('requestCamera()', () { + test('許可された場合,true を返す', () async { + platform.requestStatus = PermissionStatus.granted; + final service = PermissionService(); + + expect(await service.requestCamera(), isTrue); + expect(platform.calls, contains('requestPermissions')); + }); + + test('拒否された場合,false を返す', () async { + platform.requestStatus = PermissionStatus.denied; + final service = PermissionService(); + + expect(await service.requestCamera(), isFalse); + }); + + test('永久拒否された場合,false を返す', () async { + platform.requestStatus = PermissionStatus.permanentlyDenied; + final service = PermissionService(); + + expect(await service.requestCamera(), isFalse); + }); + }); + + group('requestStorage()', () { + test('許可された場合,true を返す', () async { + platform.requestStatus = PermissionStatus.granted; + final service = PermissionService(); + + expect(await service.requestStorage(), isTrue); + }); + + test('拒否された場合,false を返す', () async { + platform.requestStatus = PermissionStatus.denied; + final service = PermissionService(); + + expect(await service.requestStorage(), isFalse); + }); + }); + + group('isCameraPermanentlyDenied()(requestCamera 未実行)', () { + test('status が permanentlyDenied の場合,true を返す', () async { + platform.checkStatus = PermissionStatus.permanentlyDenied; + final service = PermissionService(); + + expect(await service.isCameraPermanentlyDenied(), isTrue); + expect(platform.calls, ['checkPermissionStatus']); + }); + + test('status が denied の場合,false を返す', () async { + platform.checkStatus = PermissionStatus.denied; + final service = PermissionService(); + + expect(await service.isCameraPermanentlyDenied(), isFalse); + }); + + test('status が granted の場合,false を返す', () async { + platform.checkStatus = PermissionStatus.granted; + final service = PermissionService(); + + expect(await service.isCameraPermanentlyDenied(), isFalse); + }); + }); + + group('isCameraPermanentlyDenied()(requestCamera 実行後)', () { + test( + 'request 結果が permanentlyDenied の場合,status が denied でも true を返す', + () async { + // permission_handler_android 14.1.0 以降の挙動を再現する: + // status では永久拒否が分からず,request の結果でのみ分かる. + platform.requestStatus = PermissionStatus.permanentlyDenied; + platform.checkStatus = PermissionStatus.denied; + final service = PermissionService(); + + await service.requestCamera(); + + expect(await service.isCameraPermanentlyDenied(), isTrue); + }, + ); + + test( + 'request 結果が denied の場合,status が permanentlyDenied でも false を返す', + () async { + platform.requestStatus = PermissionStatus.denied; + platform.checkStatus = PermissionStatus.permanentlyDenied; + final service = PermissionService(); + + await service.requestCamera(); + + expect(await service.isCameraPermanentlyDenied(), isFalse); + }, + ); + + test('request 結果が granted の場合,false を返す', () async { + platform.requestStatus = PermissionStatus.granted; + platform.checkStatus = PermissionStatus.permanentlyDenied; + final service = PermissionService(); + + await service.requestCamera(); + + expect(await service.isCameraPermanentlyDenied(), isFalse); + }); + + test('request 後の判定ではプラットフォームの status を問い合わせない', () async { + platform.requestStatus = PermissionStatus.permanentlyDenied; + final service = PermissionService(); + + await service.requestCamera(); + platform.calls.clear(); + await service.isCameraPermanentlyDenied(); + + expect(platform.calls, isNot(contains('checkPermissionStatus'))); + }); + + test('request を複数回実行した場合,直近の結果で判定する', () async { + final service = PermissionService(); + + platform.requestStatus = PermissionStatus.permanentlyDenied; + await service.requestCamera(); + expect(await service.isCameraPermanentlyDenied(), isTrue); + + // 設定画面で許可して戻った後の再要求を想定する. + platform.requestStatus = PermissionStatus.granted; + await service.requestCamera(); + expect(await service.isCameraPermanentlyDenied(), isFalse); + }); + + test('requestStorage の結果はカメラの永久拒否判定に影響しない', () async { + platform.checkStatus = PermissionStatus.denied; + platform.requestStatus = PermissionStatus.permanentlyDenied; + final service = PermissionService(); + + await service.requestStorage(); + + expect(await service.isCameraPermanentlyDenied(), isFalse); + }); + }); + + group('openSettings()', () { + test('アプリの設定画面を開く要求をプラットフォームへ送る', () async { + final service = PermissionService(); + + await service.openSettings(); + + expect(platform.calls, contains('openAppSettings')); + }); + }); + }); +}