diff --git a/packages/.agents/skills/dart-best-practices/SKILL.md b/packages/.agents/skills/dart-best-practices/SKILL.md new file mode 100644 index 000000000000..cfe2c41a0db8 --- /dev/null +++ b/packages/.agents/skills/dart-best-practices/SKILL.md @@ -0,0 +1,54 @@ +--- +name: dart-best-practices +description: |- + General best practices for Dart development. + Covers code style, effective Dart, and language features. +license: Apache-2.0 +--- + +# Dart Best Practices + +## 1. When to use this skill +Use this skill when: +- Writing or reviewing Dart code. +- Looking for guidance on idiomatic Dart usage. + +## 2. Best Practices + +### Multi-line Strings +Prefer using multi-line strings (`'''`) over concatenating strings with `+` and +`\n`, especially for large blocks of text like SQL queries, HTML, or +PEM-encoded keys. This improves readability and avoids +`lines_longer_than_80_chars` lint errors by allowing natural line breaks. + +**Avoid:** +```dart +final pem = '-----BEGIN RSA PRIVATE KEY-----\n' + + base64Encode(fullBytes) + + '\n-----END RSA PRIVATE KEY-----'; +``` + +**Prefer:** +```dart +final pem = ''' +-----BEGIN RSA PRIVATE KEY----- +${base64Encode(fullBytes)} +-----END RSA PRIVATE KEY-----'''; +``` + +### Line Length +Avoid lines longer than 80 characters, even in Markdown files and comments. +This ensures code is readable in split-screen views and on smaller screens +without horizontal scrolling. + +**Prefer:** +Target 80 characters for wrapping text. Exceptions are allowed for long URLs +or identifiers that cannot be broken. + +## Related Skills + +- **[dart-modern-features]**: For idiomatic + usage of modern Dart features like Pattern Matching (useful for deep JSON + extraction), Records, and Switch Expressions. + +[dart-modern-features]: https://github.com/kevmoo/dash_skills/blob/main/.agent/skills/dart-modern-features/SKILL.md diff --git a/packages/.agents/skills/dart-package-maintenance/SKILL.md b/packages/.agents/skills/dart-package-maintenance/SKILL.md new file mode 100644 index 000000000000..72d041a7ca09 --- /dev/null +++ b/packages/.agents/skills/dart-package-maintenance/SKILL.md @@ -0,0 +1,83 @@ +--- +name: dart-package-maintenance +description: |- + Guidelines for maintaining external Dart packages, covering versioning, + publishing workflows, and pull request management. Use when updating Dart + packages, preparing for a release, or managing collaborative changes in a + repository. +--- + +# Dart Package Maintenance + +Guidelines for maintaining Dart packages in alignment with Dart team best +practices. + +## Versioning + +### Semantic Versioning +- **Major**: Breaking changes. +- **Minor**: New features (non-breaking API changes). +- **Patch**: Bug fixes, documentation, or non-impacting changes. +- **Unstable packages**: Use `0.major.minor+patch`. +- **Recommendation**: Aim for `1.0.0` as soon as the package is stable. + +### Pre-Edit Verification +- **Check Published Versions**: Before modifying `CHANGELOG.md` or + `pubspec.yaml`, ALWAYS check the currently released version (e.g., via + `git tag` or `pub.dev`). +- **Do Not Amend Released Versions**: Never add new entries to a version header + that corresponds to a released tag. +- **Increment for New Changes**: If the current version in `pubspec.yaml` + matches a released tag, increment the version (e.g., usually to `-wip`) and + create a new section in `CHANGELOG.md`. + + - **Consistency**: The `CHANGELOG.md` header must match the new + `pubspec.yaml` version. + + - **SemVer Guidelines**: + - **Breaking Changes**: Bump Major, reset Minor/Patch + (e.g., `2.0.0-wip`, `0.5.0-wip`). + - **New Features**: Bump Minor, reset Patch + (e.g., `1.1.0-wip`, `0.4.5-wip`). + - **Bug Fixes**: Bump Patch (e.g., `1.0.1-wip`). + +### Changelog Content +- **Focus on User Impact**: Entries in `CHANGELOG.md` should focus on changes + visible to or impacting the end-user (e.g., new features, bug fixes, + breaking changes). +- **Omit Internal Changes**: Do not include internal refactorings, test + changes, or other modifications that do not affect the package's behavior + or API for the user. + +### Work-in-Progress (WIP) Versions +- Immediately after a publish, or on the first change after a publish, update + `pubspec.yaml` and `CHANGELOG.md` with a `-wip` suffix (e.g., `1.1.0-wip`). +- This indicates the current state is not yet published. + +### Breaking Changes +- Evaluate the impact on dependent packages and internal projects. +- Consider running changes through internal presubmits if possible. +- Prefer incremental rollouts (e.g., new behavior as opt-in) to minimize + downstream breakage. + +## Publishing Process + +1. **Preparation**: Remove the `-wip` suffix from `pubspec.yaml` and + `CHANGELOG.md` in a dedicated pull request. +2. **Execution**: Run `dart pub publish` (or `flutter pub publish`) and resolve + all warnings and errors. +3. **Tagging**: Create and push a git tag for the published version: + - For single-package repos: `v1.2.3` + - For monorepos: `package_name-v1.2.3` + - Example: `git tag v1.2.3 && git push --tags` + +## Pull Request Management + +- **Commits**: Each PR should generally correspond to a single squashed commit + upon merging. +- **Shared History**: Once a PR is open, avoid force pushing to the branch. +- **Conflict Resolution**: Prefer merging `main` into the PR branch rather than + rebasing to resolve conflicts. This preserves the review history and comments. +- **Reviewing**: Add comments from the "Files changed" view to batch them. +- **Local Inspection**: Use `gh pr checkout ` to inspect changes + locally in your IDE. diff --git a/packages/.agents/skills/dart-test-fundamentals/SKILL.md b/packages/.agents/skills/dart-test-fundamentals/SKILL.md new file mode 100644 index 000000000000..51a93811a52e --- /dev/null +++ b/packages/.agents/skills/dart-test-fundamentals/SKILL.md @@ -0,0 +1,127 @@ +--- +name: dart-test-fundamentals +description: |- + Core concepts and best practices for `package:test`. + Covers `test`, `group`, lifecycle methods (`setUp`, `tearDown`), and configuration (`dart_test.yaml`). +license: Apache-2.0 +--- + +# Dart Test Fundamentals + +## When to use this skill +Use this skill when: +- Writing new test files. +- structuring test suites with `group`. +- Configuring test execution via `dart_test.yaml`. +- Understanding test lifecycle methods. + +## Core Concepts + +### 1. Test Structure (`test` and `group`) + +- **`test`**: The fundamental unit of testing. + ```dart + test('description', () { + // assertions + }); + ``` +- **`group`**: Used to organize tests into logical blocks. + - Groups can be nested. + - Descriptions are concatenated (e.g., "Group Description Test Description"). + - Helps scope `setUp` and `tearDown` calls. + - **Naming**: Use `PascalCase` for groups that correspond to a class name + (e.g., `group('MyClient', ...)`). + - **Avoid Single Groups**: Do not wrap all tests in a file with a single + `group` call if it's the only one. + +- **Naming Tests**: + - Avoid redundant "test" prefixes. + - Include the expected behavior or outcome in the description (e.g., + `'throws StateError'` or `'adds API key to URL'`). + - Descriptions should read well when concatenated with their group name. + +- **Named Parameters Placement**: + - For `test` and `group` calls, place named parameters (e.g., `testOn`, + `timeout`, `skip`) immediately after the description string, before the + callback closure. This improves readability by keeping the test logic last. + ```dart + test('description', testOn: 'vm', () { + // assertions + }); + ``` + +### 2. Lifecycle Methods (`setUp`, `tearDown`) + +- **`setUp`**: Runs *before* every `test` in the current `group` (and nested + groups). +- **`tearDown`**: Runs *after* every `test` in the current `group`. +- **`setUpAll`**: Runs *once* before any test in the group. +- **`tearDownAll`**: Runs *once* after all tests in the group. + +**Best Practice:** +- Use `setUp` for resetting state to ensure test isolation. +- Avoid sharing mutable state between tests without resetting it. + +### 3. Configuration (`dart_test.yaml`) + +The `dart_test.yaml` file configures the test runner. Common configurations +include: + +#### Platforms +Define where tests run (vm, chrome, node). + +```yaml +platforms: + - vm + - chrome +``` + +#### Tags +Categorize tests to run specific subsets. + +```yaml +tags: + integration: + timeout: 2x +``` + +Usage in code: +```dart +@Tags(['integration']) +import 'package:test/test.dart'; +``` + +Running tags: +`dart test --tags integration` + +#### Timeouts +Set default timeouts for tests. + +```yaml +timeouts: + 2x # Double the default timeout +``` + +### 4. File Naming +- Test files **must** end in `_test.dart` to be picked up by the test runner. +- Place tests in the `test/` directory. + +## Common commands + +- `dart test`: Run all tests. +- `dart test test/path/to/file_test.dart`: Run a specific file. +- `dart test --name "substring"`: Run tests matching a description. + +## Related Skills + +`dart-test-fundamentals` is the core skill for structuring and configuring +tests. For writing assertions within those tests, refer to: + +- **[dart-matcher-best-practices]**: + Use this if the project sticks with the traditional + `package:matcher` (`expect` calls). +- **[dart-checks-migration]**: Use this + if the project is migrating to the modern `package:checks` (`check` calls). + +[dart-matcher-best-practices]: https://github.com/kevmoo/dash_skills/blob/main/.agent/skills/dart-matcher-best-practices/SKILL.md +[dart-checks-migration]: https://github.com/kevmoo/dash_skills/blob/main/.agent/skills/dart-checks-migration/SKILL.md diff --git a/packages/camera/camera_android_camerax/.agents/skills/dart-best-practices b/packages/camera/camera_android_camerax/.agents/skills/dart-best-practices new file mode 120000 index 000000000000..17a380bbe53b --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/dart-best-practices @@ -0,0 +1 @@ +.agents/skills/dart-best-practices/ \ No newline at end of file diff --git a/packages/camera/camera_android_camerax/.agents/skills/dart-package-maintenance b/packages/camera/camera_android_camerax/.agents/skills/dart-package-maintenance new file mode 120000 index 000000000000..f9bf97a16961 --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/dart-package-maintenance @@ -0,0 +1 @@ +.agents/skills/dart-package-maintenance/ \ No newline at end of file diff --git a/packages/camera/camera_android_camerax/.agents/skills/dart-test-fundamentals b/packages/camera/camera_android_camerax/.agents/skills/dart-test-fundamentals new file mode 120000 index 000000000000..6130c40b6cda --- /dev/null +++ b/packages/camera/camera_android_camerax/.agents/skills/dart-test-fundamentals @@ -0,0 +1 @@ +.agents/skills/dart-test-fundamentals/ \ No newline at end of file diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java index 509cdc7ba357..f2311ca8bd2e 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java @@ -61,4 +61,9 @@ public LiveDataProxyApi.LiveDataWrapper getZoomState(CameraInfo pigeonInstance) return new LiveDataProxyApi.LiveDataWrapper( pigeonInstance.getZoomState(), LiveDataSupportedType.ZOOM_STATE); } + + @Override + public Boolean hasFlashUnit(CameraInfo pigeonInstance) { + return pigeonInstance.hasFlashUnit(); + } } diff --git a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt index 2bdf4371dfed..b1b9d9cdb9fd 100644 --- a/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt +++ b/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraXLibrary.g.kt @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.7), do not edit directly. +// Autogenerated from Pigeon (v26.1.5), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -1301,7 +1301,7 @@ enum class CameraStateType(val raw: Int) { } } -/** The types (T) properly wrapped to be used as a LiveData. */ +/** The types (T) properly wrapped to be used as a `LiveData`. */ enum class LiveDataSupportedType(val raw: Int) { CAMERA_STATE(0), ZOOM_STATE(1); @@ -2199,6 +2199,9 @@ abstract class PigeonApiCameraInfo( pigeon_instance: androidx.camera.core.CameraInfo ): io.flutter.plugins.camerax.LiveDataProxyApi.LiveDataWrapper + /** Returns true if the camera has a flash unit. */ + abstract fun hasFlashUnit(pigeon_instance: androidx.camera.core.CameraInfo): Boolean + companion object { @Suppress("LocalVariableName") fun setUpMessageHandlers(binaryMessenger: BinaryMessenger, api: PigeonApiCameraInfo?) { @@ -2247,6 +2250,28 @@ abstract class PigeonApiCameraInfo( channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.camera_android_camerax.CameraInfo.hasFlashUnit", + codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_instanceArg = args[0] as androidx.camera.core.CameraInfo + val wrapped: List = + try { + listOf(api.hasFlashUnit(pigeon_instanceArg)) + } catch (exception: Throwable) { + CameraXLibraryPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } diff --git a/packages/camera/camera_android_camerax/implementation_plan.md b/packages/camera/camera_android_camerax/implementation_plan.md new file mode 100644 index 000000000000..d23674756eda --- /dev/null +++ b/packages/camera/camera_android_camerax/implementation_plan.md @@ -0,0 +1,93 @@ +# Fix Torch State Retention on Camera Switch in camera_android_camerax + +## Goal Description + +The `camera_android_camerax` package fails to retain the torch state when switching between cameras. Specifically, if the torch is turned on while using the rear camera, and the user switches to the front camera (which typically does not support torch) and then back to the rear camera, the torch does not turn back on automatically. Furthermore, attempting to turn it on again after switching back fails because the internal state (`torchEnabled`) still thinks it is on, causing an early return. + +This plan proposes to fix this by tracking torch state per camera and restoring it when a camera becomes active, after verifying that the camera supports flash. + +## User Review Required + +> [!IMPORTANT] +> **Multi-Camera Support**: To prevent out-of-sync issues in complex camera switching cases (e.g., devices with more than 2 cameras), I propose changing `torchEnabled` from a single boolean to a map `_torchEnabledPerCamera = {}` keyed by the camera name (from `CameraDescription.name`). This ensures torch state is isolated per camera. +> +> **New Variable**: I am adding `_currentCameraDescription` to track the active camera. This is required because some methods (like `initializeCamera`) only receive a `cameraId` (which is the texture ID) and need to know which camera description it corresponds to in order to use its name as a key in `_torchEnabledPerCamera`. + +> [!NOTE] +> **CameraX Expectations**: CameraX expects developers to check `CameraInfo.hasFlashUnit()` before calling `CameraControl.enableTorch()`. +> I will incorporate this by: +> 1. Exposing `hasFlashUnit()` via Pigeon in `CameraInfo`. +> 2. Checking it in Dart before attempting to restore torch state, and giving a helpful error if the user tries to turn on torch on a camera without flash. + +## Prerequisites + +Before making any code changes, run the following command to ensure dependencies are up to date: +```bash +dart run ../../../script/tool/bin/flutter_plugin_tools.dart fetch-deps --packages=camera_android_camerax +``` + +## Proposed Changes + +### camera_android_camerax + +Summary of changes to retain and restore torch state across camera switches. + +--- + +#### [MODIFY] [pigeons/camerax_library.dart](file:///Users/camillesimon/packages/packages/camera/camera_android_camerax/pigeons/camerax_library.dart) + +- Add `bool hasFlashUnit();` to `abstract class CameraInfo`. +- Run the Pigeon generator to update generated files. + +#### [MODIFY] [android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java](file:///Users/camillesimon/packages/packages/camera/camera_android_camerax/android/src/main/java/io/flutter/plugins/camerax/CameraInfoProxyApi.java) + +- Implement `hasFlashUnit(CameraInfo pigeonInstance)` to return `pigeonInstance.hasFlashUnit()`. + +#### [MODIFY] [lib/src/android_camera_camerax.dart](file:///Users/camillesimon/packages/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart) + +- Add `_currentCameraDescription` instance variable to track the active camera. +- Update `_currentCameraDescription` in `createCameraWithSettings` and `setDescriptionWhileRecording`. +- Replace `torchEnabled` boolean with `Map _torchEnabledPerCamera = {};`. +- Update `setFlashMode` to use `_torchEnabledPerCamera` keyed by `_currentCameraDescription.name`. If mode is `FlashMode.torch`, check `await cameraInfo!.hasFlashUnit()` first and throw a specific `CameraException` if false. +- Modify `_enableTorchMode` to accept an optional `addErrorToStream` boolean parameter (defaulting to `true`). When restoring state, we will pass `false` to avoid spamming the stream if it fails unexpectedly. +- In `_updateCameraInfoAndLiveCameraState`, add logic to restore torch state: if `_torchEnabledPerCamera[_currentCameraDescription.name]` is true, check `await cameraInfo!.hasFlashUnit()`. If true, call `_enableTorchMode(true, addErrorToStream: false)`. + +## Verification Plan + +To make commands easier to read, you can use an alias: +```bash +alias tool="dart run ../../../script/tool/bin/flutter_plugin_tools.dart" +``` + +### Automated Tests + +I will add unit tests in `android_camera_camerax_test.dart` to verify: +1. `setFlashMode` with `FlashMode.torch` sets torch state for that camera. +2. `_updateCameraInfoAndLiveCameraState` attempts to restore torch state to ON if enabled for that camera and flash is available. +3. `_updateCameraInfoAndLiveCameraState` ensures torch state is OFF if not enabled for that camera. +4. `_enableTorchMode` handles failures by not adding to stream when `addErrorToStream` is false. + +Run tests using: +```bash +tool dart-test --package=camera_android_camerax +``` + +### Codebase Health Guidelines + +- Run `tool format --package=camera_android_camerax` and `tool analyze --package=camera_android_camerax` after every code edit. +- Run `tool fix --packages=camera_android_camerax` if errors are found in analyze before attempting any other mitigations. +- Run tests after each test case added and after finishing a unit of code work. +- Run `tool gradle-check --packages=camera_android_camerax` after touching `build.gradle` files. +- Run `tool license-check --packages=camera_android_camerax` after getting new files to their final state. +- When completely done, run `tool readme-check`, `tool version-check`, `tool pubspec-check`. +- Finally, run `tool publish-check`. + +### Manual Verification + +1. **Example App**: Build and run the example app to check behavior visually. +2. **Integration Tests**: Run `flutter test` in `example/integration_test` to ensure nothing broke. +3. **Emulator Testing**: + - Start emulator: `../../Library/Android/sdk/emulator/emulator -avd Pixel_9_API_36` (or available AVD). + - Run example app: `flutter run example` + - Check torch state via adb: `adb shell dumpsys media.camera | grep -i "torch"` + - Ensure emulator has camera flash enabled in settings if testing actual toggle. diff --git a/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart b/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart index e52e4bd09cf1..2f5ccfe5f1d1 100644 --- a/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart +++ b/packages/camera/camera_android_camerax/lib/src/android_camera_camerax.dart @@ -137,9 +137,25 @@ class AndroidCameraCameraX extends CameraPlatform { /// The flash mode currently configured for [imageCapture]. CameraXFlashMode? _currentFlashMode; - /// Whether or not torch flash mode has been enabled for the [camera]. + /// The `CameraDescription` of the camera currently in use. + CameraDescription? _currentCameraDescription; + + /// Map of torch flash mode enabled state for each camera, keyed by camera name. @visibleForTesting - bool torchEnabled = false; + final Map torchEnabledPerCamera = {}; + + /// Whether or not torch flash mode has been enabled for the current camera. + @visibleForTesting + bool get torchEnabled { + final String name = _currentCameraDescription?.name ?? 'default'; + return torchEnabledPerCamera[name] ?? false; + } + + @visibleForTesting + set torchEnabled(bool value) { + final String name = _currentCameraDescription?.name ?? 'default'; + torchEnabledPerCamera[name] = value; + } /// The [ImageAnalysis] instance that can be configured to analyze individual /// frames. @@ -373,6 +389,7 @@ class AndroidCameraCameraX extends CameraPlatform { CameraDescription cameraDescription, MediaSettings? mediaSettings, ) async { + _currentCameraDescription = cameraDescription; enableRecordingAudio = mediaSettings?.enableAudio ?? false; final CameraPermissionsError? error = await systemServicesManager .requestCameraPermissions(enableRecordingAudio); @@ -980,6 +997,7 @@ class AndroidCameraCameraX extends CameraPlatform { Future setDescriptionWhileRecording( CameraDescription description, ) async { + _currentCameraDescription = description; if (recording == null) { cameraErrorStreamController.add( 'Camera description not set. No active video recording.', @@ -1121,6 +1139,17 @@ class AndroidCameraCameraX extends CameraPlatform { return; } + // Check if flash is available before enabling torch. + if (cameraInfo != null) { + final bool hasFlash = await cameraInfo!.hasFlashUnit(); + if (!hasFlash) { + throw CameraException( + 'flashModeNotSupported', + 'The camera does not support torch mode because it has no flash unit.', + ); + } + } + await _enableTorchMode(true); torchEnabled = true; } @@ -1482,6 +1511,14 @@ class AndroidCameraCameraX extends CameraPlatform { await liveCameraState?.removeObservers(); liveCameraState = await cameraInfo!.getCameraState(); await liveCameraState!.observe(_createCameraClosingObserver(cameraId)); + + // Restore torch state if enabled for this camera. + if (torchEnabled) { + final bool hasFlash = await cameraInfo!.hasFlashUnit(); + if (hasFlash) { + await _enableTorchMode(true, addErrorToStream: false); + } + } } /// Creates [Observer] of the [CameraState] that will: @@ -1864,13 +1901,18 @@ class AndroidCameraCameraX extends CameraPlatform { ]; } - Future _enableTorchMode(bool value) async { + Future _enableTorchMode( + bool value, { + bool addErrorToStream = true, + }) async { try { await cameraControl.enableTorch(value); } on PlatformException catch (e) { - cameraErrorStreamController.add( - e.message ?? 'The camera was unable to change torch modes.', - ); + if (addErrorToStream) { + cameraErrorStreamController.add( + e.message ?? 'The camera was unable to change torch modes.', + ); + } } } diff --git a/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart b/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart index 7b642695e0b0..0f58c0c7fce1 100644 --- a/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart +++ b/packages/camera/camera_android_camerax/lib/src/camerax_library.g.dart @@ -1,7 +1,7 @@ // Copyright 2013 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// Autogenerated from Pigeon (v26.1.7), do not edit directly. +// Autogenerated from Pigeon (v26.1.5), do not edit directly. // See also: https://pub.dev/packages/pigeon // ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, omit_obvious_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers @@ -926,7 +926,7 @@ enum CameraStateType { unknown, } -/// The types (T) properly wrapped to be used as a LiveData. +/// The types (T) properly wrapped to be used as a `LiveData`. enum LiveDataSupportedType { cameraState, zoomState } /// Video quality constraints that will be used by a QualitySelector to choose @@ -2282,6 +2282,40 @@ class CameraInfo extends PigeonInternalProxyApiBaseClass { } } + /// Returns true if the camera has a flash unit. + Future hasFlashUnit() async { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = + _pigeonVar_codecCameraInfo; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const pigeonVar_channelName = + 'dev.flutter.pigeon.camera_android_camerax.CameraInfo.hasFlashUnit'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [this], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + @override CameraInfo pigeon_copy() { return CameraInfo.pigeon_detached( diff --git a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart index 611157f65248..ccb9efed3a30 100644 --- a/packages/camera/camera_android_camerax/pigeons/camerax_library.dart +++ b/packages/camera/camera_android_camerax/pigeons/camerax_library.dart @@ -262,6 +262,9 @@ abstract class CameraInfo { /// A LiveData of ZoomState. LiveData getZoomState(); + + /// Returns true if the camera has a flash unit. + bool hasFlashUnit(); } /// Direction of lens of a camera. diff --git a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart index 71ed3d58ccde..cba093360628 100644 --- a/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart +++ b/packages/camera/camera_android_camerax/test/android_camera_camerax_test.dart @@ -17,6 +17,16 @@ import 'package:mockito/mockito.dart'; import 'android_camera_camerax_test.mocks.dart'; +class FakeCaptureRequestOptions extends Fake implements CaptureRequestOptions { + final Map _options; + FakeCaptureRequestOptions(this._options); + + @override + Future getCaptureRequestOption(CaptureRequestKey key) async { + return _options[key]; + } +} + @GenerateNiceMocks(>[ MockSpec(), MockSpec(), @@ -484,13 +494,7 @@ void main() { PigeonOverrides.captureRequestOptions_new = ({required Map options}) { - final mockCaptureRequestOptions = MockCaptureRequestOptions(); - options.forEach((CaptureRequestKey key, Object? value) { - when( - mockCaptureRequestOptions.getCaptureRequestOption(key), - ).thenAnswer((_) async => value); - }); - return mockCaptureRequestOptions; + return FakeCaptureRequestOptions(options); }; PigeonOverrides.captureRequest_controlAELock = CaptureRequestKey.pigeon_detached(); @@ -4036,6 +4040,182 @@ void main() { }, ); + test( + 'setFlashMode throws exception when torch mode set but no flash unit', + () async { + final camera = AndroidCameraCameraX(); + const cameraId = 44; + final mockCameraControl = MockCameraControl(); + final mockCameraInfo = MockCameraInfo(); + + camera.cameraControl = mockCameraControl; + camera.cameraInfo = mockCameraInfo; + + when(mockCameraInfo.hasFlashUnit()).thenAnswer((_) async => false); + + expect( + () => camera.setFlashMode(cameraId, FlashMode.torch), + throwsA( + isA().having( + (CameraException e) => e.code, + 'code', + 'flashModeNotSupported', + ), + ), + ); + }, + ); + + test( + 'initializeCamera restores torch state to ON if enabled and flash available', + () async { + final camera = AndroidCameraCameraX(); + const cameraId = 44; + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockLiveCameraState = MockLiveCameraState(); + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.preview = MockPreview(); + camera.imageCapture = MockImageCapture(); + camera.imageAnalysis = MockImageAnalysis(); + + when( + mockProcessCameraProvider.bindToLifecycle(any, any), + ).thenAnswer((_) async => mockCamera); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockCamera.cameraControl).thenReturn(mockCameraControl); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => mockLiveCameraState); + when(mockCameraInfo.hasFlashUnit()).thenAnswer((_) async => true); + + camera.torchEnabled = true; + + await camera.initializeCamera(cameraId); + + verify(mockCameraControl.enableTorch(true)); + }, + ); + + test('initializeCamera ensures torch state is OFF if not enabled', () async { + final camera = AndroidCameraCameraX(); + const cameraId = 44; + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockLiveCameraState = MockLiveCameraState(); + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.preview = MockPreview(); + camera.imageCapture = MockImageCapture(); + camera.imageAnalysis = MockImageAnalysis(); + + when( + mockProcessCameraProvider.bindToLifecycle(any, any), + ).thenAnswer((_) async => mockCamera); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockCamera.cameraControl).thenReturn(mockCameraControl); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => mockLiveCameraState); + when(mockCameraInfo.hasFlashUnit()).thenAnswer((_) async => true); + + camera.torchEnabled = false; + + await camera.initializeCamera(cameraId); + + verifyNever(mockCameraControl.enableTorch(any)); + }); + + test( + 'initializeCamera does not restore torch state if enabled but no flash unit', + () async { + final camera = AndroidCameraCameraX(); + const cameraId = 44; + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockLiveCameraState = MockLiveCameraState(); + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.preview = MockPreview(); + camera.imageCapture = MockImageCapture(); + camera.imageAnalysis = MockImageAnalysis(); + + when( + mockProcessCameraProvider.bindToLifecycle(any, any), + ).thenAnswer((_) async => mockCamera); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockCamera.cameraControl).thenReturn(mockCameraControl); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => mockLiveCameraState); + when(mockCameraInfo.hasFlashUnit()).thenAnswer((_) async => false); + + camera.torchEnabled = true; + + await camera.initializeCamera(cameraId); + + verifyNever(mockCameraControl.enableTorch(any)); + }, + ); + + test( + 'initializeCamera does not emit error when restore torch fails', + () async { + final camera = AndroidCameraCameraX(); + const cameraId = 44; + final mockProcessCameraProvider = MockProcessCameraProvider(); + final mockCamera = MockCamera(); + final mockCameraInfo = MockCameraInfo(); + final mockCameraControl = MockCameraControl(); + final mockLiveCameraState = MockLiveCameraState(); + + camera.processCameraProvider = mockProcessCameraProvider; + camera.cameraSelector = MockCameraSelector(); + camera.preview = MockPreview(); + camera.imageCapture = MockImageCapture(); + camera.imageAnalysis = MockImageAnalysis(); + + when( + mockProcessCameraProvider.bindToLifecycle(any, any), + ).thenAnswer((_) async => mockCamera); + when(mockCamera.getCameraInfo()).thenAnswer((_) async => mockCameraInfo); + when(mockCamera.cameraControl).thenReturn(mockCameraControl); + when( + mockCameraInfo.getCameraState(), + ).thenAnswer((_) async => mockLiveCameraState); + when(mockCameraInfo.hasFlashUnit()).thenAnswer((_) async => true); + + camera.torchEnabled = true; + + when(mockCameraControl.enableTorch(true)).thenThrow( + PlatformException(code: 'camera_error', message: 'Torch failed'), + ); + + var errorEmitted = false; + final StreamSubscription subscription = + camera.onCameraError(cameraId).listen((_) { + errorEmitted = true; + }); + + await camera.initializeCamera(cameraId); + + await Future.delayed(const Duration(milliseconds: 100)); + + expect(errorEmitted, isFalse); + await subscription.cancel(); + }, + ); + test('getMinExposureOffset returns expected exposure offset', () async { final camera = AndroidCameraCameraX(); final mockCameraInfo = MockCameraInfo(); diff --git a/packages/camera/camera_android_camerax/walkthrough.md b/packages/camera/camera_android_camerax/walkthrough.md new file mode 100644 index 000000000000..b52688f23dd9 --- /dev/null +++ b/packages/camera/camera_android_camerax/walkthrough.md @@ -0,0 +1,45 @@ +# Walkthrough - Fix Torch State Retention + +I have implemented the changes to fix the torch state retention issue in the `camera_android_camerax` package. However, I encountered a permission error when trying to run the tests and commands in my environment. I need you to run the tests to verify the changes. + +## Changes Made + +### Pigeon Layer +- Added `bool hasFlashUnit()` to `CameraInfo` in `pigeons/camerax_library.dart`. +- Ran Pigeon generator to update generated Dart and Kotlin files. + +### Native Layer (Android) +- Implemented `hasFlashUnit` in `CameraInfoProxyApi.java` to delegate to CameraX's `CameraInfo.hasFlashUnit()`. + +### Dart Layer +- Replaced `torchEnabled` with a map `torchEnabledPerCamera` in `android_camera_camerax.dart` to track torch state per camera. +- Added `_currentCameraDescription` to track the active camera. +- Updated `setFlashMode` to check `hasFlashUnit` before enabling torch and to use the map. +- Updated `_updateCameraInfoAndLiveCameraState` to restore torch state when a camera becomes active. +- Renamed `isRestore` to `addErrorToStream` in `_enableTorchMode`. + +### Tests +- Added unit tests in `android_camera_camerax_test.dart` for: + - `setFlashMode` throwing exception if no flash unit. + - Restoring torch state to ON/OFF on initialization. + - Handling failures silently during restore. +- **Fixed pre-existing Mockito issue**: Replaced a `when` call inside a stub response with `FakeCaptureRequestOptions` to fix "Cannot call `when` within a stub response" error that was causing 52 tests to fail. + +## What Needs to be Tested + +Please run the following commands from the package directory (`packages/camera/camera_android_camerax`) to verify the changes: + +1. **Run Unit Tests**: + ```bash + dart run ../../../script/tool/bin/flutter_plugin_tools.dart dart-test --packages=camera_android_camerax + ``` + (Or run `dart test test/android_camera_camerax_test.dart` directly). + +2. **Run Health Checks**: + ```bash + dart run ../../../script/tool/bin/flutter_plugin_tools.dart format --package=camera_android_camerax + dart run ../../../script/tool/bin/flutter_plugin_tools.dart analyze --package=camera_android_camerax + ``` + +3. **Manual Verification**: + Follow the steps in the implementation plan to test with an emulator if possible. diff --git a/packages/skills-lock.json b/packages/skills-lock.json new file mode 100644 index 000000000000..8ce4c50aa52e --- /dev/null +++ b/packages/skills-lock.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "skills": { + "dart-best-practices": { + "source": "kevmoo/dash_skills", + "sourceType": "github", + "computedHash": "1eb4a38aa94060d3d420f1b03b661ad784a1cd732ffb7ff4d8369d085024d4f5" + }, + "dart-package-maintenance": { + "source": "kevmoo/dash_skills", + "sourceType": "github", + "computedHash": "38a88563712853149984bd823c41fa3b7c56400e5936c3166cdd2075d00f90ff" + }, + "dart-test-fundamentals": { + "source": "kevmoo/dash_skills", + "sourceType": "github", + "computedHash": "eb99d0125ce554ac55fd85cfe865fbd703f5f311cab5bd551ce45c1f55af2adf" + } + } +}