diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md index 079aa1685bd5..f1d771496dde 100644 --- a/packages/camera/camera/CHANGELOG.md +++ b/packages/camera/camera/CHANGELOG.md @@ -1,18 +1,9 @@ -## 0.8.0-nullsafety.3 - -* Updates the example code listed in the [README.md](README.md), so it runs without errors when you simply copy/ paste it into a Flutter App. - -## 0.8.0-nullsafety.2 +## 0.8.0 +* Stable null safety release. * Solved delay when using the zoom feature on iOS. - -## 0.8.0-nullsafety.1 - * Added a timeout to the pre-capture sequence on Android to prevent crashes when the camera cannot get a focus. - -## 0.8.0-nullsafety - -* Migrated to null safety. +* Updates the example code listed in the [README.md](README.md), so it runs without errors when you simply copy/ paste it into a Flutter App. ## 0.7.0+4 diff --git a/packages/camera/camera/example/integration_test/camera_test.dart b/packages/camera/camera/example/integration_test/camera_test.dart index c2e73e0f1563..4ff624c7d989 100644 --- a/packages/camera/camera/example/integration_test/camera_test.dart +++ b/packages/camera/camera/example/integration_test/camera_test.dart @@ -1,3 +1,10 @@ +// Copyright 2019, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// TODO(mvanbeusekom): Remove this once flutter_driver supports null safety. +// https://github.com/flutter/flutter/issues/71379 +// @dart = 2.9 import 'dart:async'; import 'dart:io'; import 'dart:ui'; diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart index 6244aa5a8e37..5eebc9a379ca 100644 --- a/packages/camera/camera/example/lib/main.dart +++ b/packages/camera/camera/example/lib/main.dart @@ -27,32 +27,38 @@ IconData getCameraLensIcon(CameraLensDirection direction) { return Icons.camera_front; case CameraLensDirection.external: return Icons.camera; + default: + throw ArgumentError('Unknown lens direction'); } - throw ArgumentError('Unknown lens direction'); } -void logError(String code, String message) => +void logError(String code, String? message) { + if (message != null) { print('Error: $code\nError Message: $message'); + } else { + print('Error: $code'); + } +} class _CameraExampleHomeState extends State with WidgetsBindingObserver, TickerProviderStateMixin { - CameraController controller; - XFile imageFile; - XFile videoFile; - VideoPlayerController videoController; - VoidCallback videoPlayerListener; + CameraController? controller; + XFile? imageFile; + XFile? videoFile; + VideoPlayerController? videoController; + VoidCallback? videoPlayerListener; bool enableAudio = true; double _minAvailableExposureOffset = 0.0; double _maxAvailableExposureOffset = 0.0; double _currentExposureOffset = 0.0; - AnimationController _flashModeControlRowAnimationController; - Animation _flashModeControlRowAnimation; - AnimationController _exposureModeControlRowAnimationController; - Animation _exposureModeControlRowAnimation; - AnimationController _focusModeControlRowAnimationController; - Animation _focusModeControlRowAnimation; - double _minAvailableZoom; - double _maxAvailableZoom; + late AnimationController _flashModeControlRowAnimationController; + late Animation _flashModeControlRowAnimation; + late AnimationController _exposureModeControlRowAnimationController; + late Animation _exposureModeControlRowAnimation; + late AnimationController _focusModeControlRowAnimationController; + late Animation _focusModeControlRowAnimation; + double _minAvailableZoom = 1.0; + double _maxAvailableZoom = 1.0; double _currentScale = 1.0; double _baseScale = 1.0; @@ -62,7 +68,8 @@ class _CameraExampleHomeState extends State @override void initState() { super.initState(); - WidgetsBinding.instance.addObserver(this); + WidgetsBinding.instance?.addObserver(this); + _flashModeControlRowAnimationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, @@ -91,7 +98,7 @@ class _CameraExampleHomeState extends State @override void dispose() { - WidgetsBinding.instance.removeObserver(this); + WidgetsBinding.instance?.removeObserver(this); _flashModeControlRowAnimationController.dispose(); _exposureModeControlRowAnimationController.dispose(); super.dispose(); @@ -99,16 +106,17 @@ class _CameraExampleHomeState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { + final CameraController? cameraController = controller; + // App state changed before we got the chance to initialize. - if (controller == null || !controller.value.isInitialized) { + if (cameraController == null || !cameraController.value.isInitialized) { return; } + if (state == AppLifecycleState.inactive) { - controller?.dispose(); + cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { - if (controller != null) { - onNewCameraSelected(controller.description); - } + onNewCameraSelected(cameraController.description); } } @@ -134,9 +142,10 @@ class _CameraExampleHomeState extends State decoration: BoxDecoration( color: Colors.black, border: Border.all( - color: controller != null && controller.value.isRecordingVideo - ? Colors.redAccent - : Colors.grey, + color: + controller != null && controller!.value.isRecordingVideo + ? Colors.redAccent + : Colors.grey, width: 3.0, ), ), @@ -161,7 +170,9 @@ class _CameraExampleHomeState extends State /// Display the preview from the camera (or a message if the preview is not available). Widget _cameraPreviewWidget() { - if (controller == null || !controller.value.isInitialized) { + final CameraController? cameraController = controller; + + if (cameraController == null || !cameraController.value.isInitialized) { return const Text( 'Tap a camera', style: TextStyle( @@ -175,7 +186,7 @@ class _CameraExampleHomeState extends State onPointerDown: (_) => _pointers++, onPointerUp: (_) => _pointers--, child: CameraPreview( - controller, + controller!, child: LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { return GestureDetector( @@ -196,37 +207,40 @@ class _CameraExampleHomeState extends State Future _handleScaleUpdate(ScaleUpdateDetails details) async { // When there are not exactly two fingers on screen don't scale - if (_pointers != 2) { + if (controller == null || _pointers != 2) { return; } _currentScale = (_baseScale * details.scale) .clamp(_minAvailableZoom, _maxAvailableZoom); - await controller.setZoomLevel(_currentScale); + await controller!.setZoomLevel(_currentScale); } /// Display the thumbnail of the captured image or video. Widget _thumbnailWidget() { + final VideoPlayerController? localVideoController = videoController; + return Expanded( child: Align( alignment: Alignment.centerRight, child: Row( mainAxisSize: MainAxisSize.min, children: [ - videoController == null && imageFile == null + localVideoController == null && imageFile == null ? Container() : SizedBox( - child: (videoController == null) - ? Image.file(File(imageFile.path)) + child: (localVideoController == null) + ? Image.file(File(imageFile!.path)) : Container( child: Center( child: AspectRatio( aspectRatio: - videoController.value.size != null - ? videoController.value.aspectRatio + localVideoController.value.size != null + ? localVideoController + .value.aspectRatio : 1.0, - child: VideoPlayer(videoController)), + child: VideoPlayer(localVideoController)), ), decoration: BoxDecoration( border: Border.all(color: Colors.pink)), @@ -270,7 +284,7 @@ class _CameraExampleHomeState extends State onPressed: controller != null ? onAudioModeButtonPressed : null, ), IconButton( - icon: Icon(controller?.value?.isCaptureOrientationLocked ?? false + icon: Icon(controller?.value.isCaptureOrientationLocked ?? false ? Icons.screen_lock_rotation : Icons.screen_rotation), color: Colors.blue, @@ -297,7 +311,7 @@ class _CameraExampleHomeState extends State children: [ IconButton( icon: Icon(Icons.flash_off), - color: controller?.value?.flashMode == FlashMode.off + color: controller?.value.flashMode == FlashMode.off ? Colors.orange : Colors.blue, onPressed: controller != null @@ -306,7 +320,7 @@ class _CameraExampleHomeState extends State ), IconButton( icon: Icon(Icons.flash_auto), - color: controller?.value?.flashMode == FlashMode.auto + color: controller?.value.flashMode == FlashMode.auto ? Colors.orange : Colors.blue, onPressed: controller != null @@ -315,7 +329,7 @@ class _CameraExampleHomeState extends State ), IconButton( icon: Icon(Icons.flash_on), - color: controller?.value?.flashMode == FlashMode.always + color: controller?.value.flashMode == FlashMode.always ? Colors.orange : Colors.blue, onPressed: controller != null @@ -324,7 +338,7 @@ class _CameraExampleHomeState extends State ), IconButton( icon: Icon(Icons.highlight), - color: controller?.value?.flashMode == FlashMode.torch + color: controller?.value.flashMode == FlashMode.torch ? Colors.orange : Colors.blue, onPressed: controller != null @@ -339,12 +353,12 @@ class _CameraExampleHomeState extends State Widget _exposureModeControlRowWidget() { final ButtonStyle styleAuto = TextButton.styleFrom( - primary: controller?.value?.exposureMode == ExposureMode.auto + primary: controller?.value.exposureMode == ExposureMode.auto ? Colors.orange : Colors.blue, ); final ButtonStyle styleLocked = TextButton.styleFrom( - primary: controller?.value?.exposureMode == ExposureMode.locked + primary: controller?.value.exposureMode == ExposureMode.locked ? Colors.orange : Colors.blue, ); @@ -371,8 +385,10 @@ class _CameraExampleHomeState extends State onSetExposureModeButtonPressed(ExposureMode.auto) : null, onLongPress: () { - if (controller != null) controller.setExposurePoint(null); - showInSnackBar('Resetting exposure point'); + if (controller != null) { + controller!.setExposurePoint(null); + showInSnackBar('Resetting exposure point'); + } }, ), TextButton( @@ -415,12 +431,12 @@ class _CameraExampleHomeState extends State Widget _focusModeControlRowWidget() { final ButtonStyle styleAuto = TextButton.styleFrom( - primary: controller?.value?.focusMode == FocusMode.auto + primary: controller?.value.focusMode == FocusMode.auto ? Colors.orange : Colors.blue, ); final ButtonStyle styleLocked = TextButton.styleFrom( - primary: controller?.value?.focusMode == FocusMode.locked + primary: controller?.value.focusMode == FocusMode.locked ? Colors.orange : Colors.blue, ); @@ -446,7 +462,7 @@ class _CameraExampleHomeState extends State ? () => onSetFocusModeButtonPressed(FocusMode.auto) : null, onLongPress: () { - if (controller != null) controller.setFocusPoint(null); + if (controller != null) controller!.setFocusPoint(null); showInSnackBar('Resetting focus point'); }, ), @@ -468,6 +484,8 @@ class _CameraExampleHomeState extends State /// Display the control bar with buttons to take pictures and record videos. Widget _captureControlRowWidget() { + final CameraController? cameraController = controller; + return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisSize: MainAxisSize.max, @@ -475,40 +493,41 @@ class _CameraExampleHomeState extends State IconButton( icon: const Icon(Icons.camera_alt), color: Colors.blue, - onPressed: controller != null && - controller.value.isInitialized && - !controller.value.isRecordingVideo + onPressed: cameraController != null && + cameraController.value.isInitialized && + !cameraController.value.isRecordingVideo ? onTakePictureButtonPressed : null, ), IconButton( icon: const Icon(Icons.videocam), color: Colors.blue, - onPressed: controller != null && - controller.value.isInitialized && - !controller.value.isRecordingVideo + onPressed: cameraController != null && + cameraController.value.isInitialized && + !cameraController.value.isRecordingVideo ? onVideoRecordButtonPressed : null, ), IconButton( - icon: controller != null && controller.value.isRecordingPaused + icon: cameraController != null && + cameraController.value.isRecordingPaused ? Icon(Icons.play_arrow) : Icon(Icons.pause), color: Colors.blue, - onPressed: controller != null && - controller.value.isInitialized && - controller.value.isRecordingVideo - ? (controller != null && controller.value.isRecordingPaused + onPressed: cameraController != null && + cameraController.value.isInitialized && + cameraController.value.isRecordingVideo + ? (cameraController.value.isRecordingPaused) ? onResumeButtonPressed - : onPauseButtonPressed) + : onPauseButtonPressed : null, ), IconButton( icon: const Icon(Icons.stop), color: Colors.red, - onPressed: controller != null && - controller.value.isInitialized && - controller.value.isRecordingVideo + onPressed: cameraController != null && + cameraController.value.isInitialized && + cameraController.value.isRecordingVideo ? onStopButtonPressed : null, ) @@ -520,6 +539,14 @@ class _CameraExampleHomeState extends State Widget _cameraTogglesRowWidget() { final List toggles = []; + final onChanged = (CameraDescription? description) { + if (description == null) { + return; + } + + onNewCameraSelected(description); + }; + if (cameras.isEmpty) { return const Text('No camera found'); } else { @@ -531,9 +558,10 @@ class _CameraExampleHomeState extends State title: Icon(getCameraLensIcon(cameraDescription.lensDirection)), groupValue: controller?.description, value: cameraDescription, - onChanged: controller != null && controller.value.isRecordingVideo - ? null - : onNewCameraSelected, + onChanged: + controller != null && controller!.value.isRecordingVideo + ? null + : onChanged, ), ), ); @@ -547,48 +575,60 @@ class _CameraExampleHomeState extends State void showInSnackBar(String message) { // ignore: deprecated_member_use - _scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(message))); + _scaffoldKey.currentState?.showSnackBar(SnackBar(content: Text(message))); } void onViewFinderTap(TapDownDetails details, BoxConstraints constraints) { + if (controller == null) { + return; + } + + final CameraController cameraController = controller!; + final offset = Offset( details.localPosition.dx / constraints.maxWidth, details.localPosition.dy / constraints.maxHeight, ); - controller.setExposurePoint(offset); - controller.setFocusPoint(offset); + cameraController.setExposurePoint(offset); + cameraController.setFocusPoint(offset); } void onNewCameraSelected(CameraDescription cameraDescription) async { if (controller != null) { - await controller.dispose(); + await controller!.dispose(); } - controller = CameraController( + final CameraController cameraController = CameraController( cameraDescription, ResolutionPreset.medium, enableAudio: enableAudio, imageFormatGroup: ImageFormatGroup.jpeg, ); + controller = cameraController; // If the controller is updated then update the UI. - controller.addListener(() { + cameraController.addListener(() { if (mounted) setState(() {}); - if (controller.value.hasError) { - showInSnackBar('Camera error ${controller.value.errorDescription}'); + if (cameraController.value.hasError) { + showInSnackBar( + 'Camera error ${cameraController.value.errorDescription}'); } }); try { - await controller.initialize(); + await cameraController.initialize(); await Future.wait([ - controller + cameraController .getMinExposureOffset() .then((value) => _minAvailableExposureOffset = value), - controller + cameraController .getMaxExposureOffset() .then((value) => _maxAvailableExposureOffset = value), - controller.getMaxZoomLevel().then((value) => _maxAvailableZoom = value), - controller.getMinZoomLevel().then((value) => _minAvailableZoom = value), + cameraController + .getMaxZoomLevel() + .then((value) => _maxAvailableZoom = value), + cameraController + .getMinZoomLevel() + .then((value) => _minAvailableZoom = value), ]); } on CameraException catch (e) { _showCameraException(e); @@ -600,7 +640,7 @@ class _CameraExampleHomeState extends State } void onTakePictureButtonPressed() { - takePicture().then((XFile file) { + takePicture().then((XFile? file) { if (mounted) { setState(() { imageFile = file; @@ -645,19 +685,20 @@ class _CameraExampleHomeState extends State void onAudioModeButtonPressed() { enableAudio = !enableAudio; if (controller != null) { - onNewCameraSelected(controller.description); + onNewCameraSelected(controller!.description); } } void onCaptureOrientationLockButtonPressed() async { if (controller != null) { - if (controller.value.isCaptureOrientationLocked) { - await controller.unlockCaptureOrientation(); + final CameraController cameraController = controller!; + if (cameraController.value.isCaptureOrientationLocked) { + await cameraController.unlockCaptureOrientation(); showInSnackBar('Capture orientation unlocked'); } else { - await controller.lockCaptureOrientation(); + await cameraController.lockCaptureOrientation(); showInSnackBar( - 'Capture orientation locked to ${controller.value.lockedCaptureOrientation.toString().split('.').last}'); + 'Capture orientation locked to ${cameraController.value.lockedCaptureOrientation.toString().split('.').last}'); } } } @@ -715,31 +756,35 @@ class _CameraExampleHomeState extends State } Future startVideoRecording() async { - if (!controller.value.isInitialized) { + final CameraController? cameraController = controller; + + if (cameraController == null || !cameraController.value.isInitialized) { showInSnackBar('Error: select a camera first.'); return; } - if (controller.value.isRecordingVideo) { + if (cameraController.value.isRecordingVideo) { // A recording is already started, do nothing. return; } try { - await controller.startVideoRecording(); + await cameraController.startVideoRecording(); } on CameraException catch (e) { _showCameraException(e); return; } } - Future stopVideoRecording() async { - if (!controller.value.isRecordingVideo) { + Future stopVideoRecording() async { + final CameraController? cameraController = controller; + + if (cameraController == null || !cameraController.value.isRecordingVideo) { return null; } try { - return controller.stopVideoRecording(); + return cameraController.stopVideoRecording(); } on CameraException catch (e) { _showCameraException(e); return null; @@ -747,12 +792,14 @@ class _CameraExampleHomeState extends State } Future pauseVideoRecording() async { - if (!controller.value.isRecordingVideo) { + final CameraController? cameraController = controller; + + if (cameraController == null || !cameraController.value.isRecordingVideo) { return null; } try { - await controller.pauseVideoRecording(); + await cameraController.pauseVideoRecording(); } on CameraException catch (e) { _showCameraException(e); rethrow; @@ -760,12 +807,14 @@ class _CameraExampleHomeState extends State } Future resumeVideoRecording() async { - if (!controller.value.isRecordingVideo) { + final CameraController? cameraController = controller; + + if (cameraController == null || !cameraController.value.isRecordingVideo) { return null; } try { - await controller.resumeVideoRecording(); + await cameraController.resumeVideoRecording(); } on CameraException catch (e) { _showCameraException(e); rethrow; @@ -773,8 +822,12 @@ class _CameraExampleHomeState extends State } Future setFlashMode(FlashMode mode) async { + if (controller == null) { + return; + } + try { - await controller.setFlashMode(mode); + await controller!.setFlashMode(mode); } on CameraException catch (e) { _showCameraException(e); rethrow; @@ -782,8 +835,12 @@ class _CameraExampleHomeState extends State } Future setExposureMode(ExposureMode mode) async { + if (controller == null) { + return; + } + try { - await controller.setExposureMode(mode); + await controller!.setExposureMode(mode); } on CameraException catch (e) { _showCameraException(e); rethrow; @@ -791,11 +848,15 @@ class _CameraExampleHomeState extends State } Future setExposureOffset(double offset) async { + if (controller == null) { + return; + } + setState(() { _currentExposureOffset = offset; }); try { - offset = await controller.setExposureOffset(offset); + offset = await controller!.setExposureOffset(offset); } on CameraException catch (e) { _showCameraException(e); rethrow; @@ -803,8 +864,12 @@ class _CameraExampleHomeState extends State } Future setFocusMode(FocusMode mode) async { + if (controller == null) { + return; + } + try { - await controller.setFocusMode(mode); + await controller!.setFocusMode(mode); } on CameraException catch (e) { _showCameraException(e); rethrow; @@ -812,16 +877,20 @@ class _CameraExampleHomeState extends State } Future _startVideoPlayer() async { + if (videoFile == null) { + return; + } + final VideoPlayerController vController = - VideoPlayerController.file(File(videoFile.path)); + VideoPlayerController.file(File(videoFile!.path)); videoPlayerListener = () { - if (videoController != null && videoController.value.size != null) { + if (videoController != null && videoController!.value.size != null) { // Refreshing the state to update video player with the correct ratio. if (mounted) setState(() {}); - videoController.removeListener(videoPlayerListener); + videoController!.removeListener(videoPlayerListener!); } }; - vController.addListener(videoPlayerListener); + vController.addListener(videoPlayerListener!); await vController.setLooping(true); await vController.initialize(); await videoController?.dispose(); @@ -834,19 +903,20 @@ class _CameraExampleHomeState extends State await vController.play(); } - Future takePicture() async { - if (!controller.value.isInitialized) { + Future takePicture() async { + final CameraController? cameraController = controller; + if (cameraController == null || !cameraController.value.isInitialized) { showInSnackBar('Error: select a camera first.'); return null; } - if (controller.value.isTakingPicture) { + if (cameraController.value.isTakingPicture) { // A capture is already pending, do nothing. return null; } try { - XFile file = await controller.takePicture(); + XFile file = await cameraController.takePicture(); return file; } on CameraException catch (e) { _showCameraException(e); diff --git a/packages/camera/camera/example/pubspec.yaml b/packages/camera/camera/example/pubspec.yaml index 2a45fd69194c..b3ef8e497dc1 100644 --- a/packages/camera/camera/example/pubspec.yaml +++ b/packages/camera/camera/example/pubspec.yaml @@ -9,23 +9,23 @@ dependencies: # The example app is bundled with the plugin so we use a path dependency on # the parent directory to use the current plugin's version. path: ../ - path_provider: ^0.5.0 + path_provider: ^2.0.0 flutter: sdk: flutter - video_player: ^0.10.0 - integration_test: - path: ../../../integration_test + video_player: ^2.0.0 dev_dependencies: flutter_test: sdk: flutter flutter_driver: sdk: flutter - pedantic: ^1.8.0 + integration_test: + path: ../../../integration_test + pedantic: ^1.10.0 flutter: uses-material-design: true environment: - sdk: ">=2.7.0 <3.0.0" - flutter: ">=1.9.1+hotfix.4" + sdk: ">=2.12.0-259.9.beta <3.0.0" + flutter: ">=1.22.0" diff --git a/packages/camera/camera/example/test_driver/integration_test.dart b/packages/camera/camera/example/test_driver/integration_test.dart index 1e6e3ba7941f..160b48f8f72f 100644 --- a/packages/camera/camera/example/test_driver/integration_test.dart +++ b/packages/camera/camera/example/test_driver/integration_test.dart @@ -1,3 +1,10 @@ +// Copyright 2019, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// TODO(mvanbeusekom): Remove this once flutter_driver supports null safety. +// https://github.com/flutter/flutter/issues/71379 +// @dart = 2.9 import 'dart:async'; import 'dart:convert'; import 'dart:io'; diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml index 2d620505def2..53f9b3b40ad1 100644 --- a/packages/camera/camera/pubspec.yaml +++ b/packages/camera/camera/pubspec.yaml @@ -2,26 +2,31 @@ name: camera description: A Flutter plugin for getting information about and controlling the camera on Android and iOS. Supports previewing the camera feed, capturing images, capturing video, and streaming image buffers to dart. -version: 0.8.0-nullsafety.3 +version: 0.8.0 homepage: https://github.com/flutter/plugins/tree/master/packages/camera/camera dependencies: flutter: sdk: flutter - camera_platform_interface: ^2.0.0-nullsafety + camera_platform_interface: ^2.0.0 pedantic: ^1.10.0 - quiver: ^3.0.0-nullsafety.3 + quiver: ^3.0.0 dev_dependencies: - video_player: ^2.0.0-nullsafety.7 + video_player: ^2.0.0 flutter_test: sdk: flutter flutter_driver: sdk: flutter - mockito: ^5.0.0-nullsafety.5 - plugin_platform_interface: ^1.1.0-nullsafety.2 + + # TODO(mvanbeusekom): Update to stable 5.0.0 release when dependency conflict + # with flutter_driver has been resolved: + # Because mockito >=5.0.0 depends on analyzer ^1.0.0 which depends on crypto ^3.0.0 + # every version of flutter_driver from sdk depends on crypto 2.1.5. + mockito: ^5.0.0-nullsafety.7 + plugin_platform_interface: ^2.0.0 flutter: plugin: @@ -33,5 +38,5 @@ flutter: pluginClass: CameraPlugin environment: - sdk: '>=2.12.0-0 <3.0.0' + sdk: ">=2.12.0-259.9.beta <3.0.0" flutter: ">=1.22.0"