Skip to content

Race condition in the default captureControlBuilder #80

Description

@martin-braun

At least on iOS, when hitting the capture button rapidly twice, it will try to take the picture twice which results in

CameraException (CameraException(No camera is streaming images, stopImageStream was called when no camera is streaming images.))

since the camera is off after the first picture has been taken. This is, because the button isn't immediately disabled when clicking it, leading to such race condition.

Anyone who doesn't want their app to crash needs to implement their own custom capture button:

import 'dart:async';
import 'dart:io';

import 'package:access_control/constants/ui_value_keys.dart';
import 'package:access_control/l10n/app_localizations.dart';
import 'package:access_control/ui/commons/trailing_app_bar_button.dart';
import 'package:access_control/ui/style.dart';
import 'package:access_control/util/debug.dart';
import 'package:access_control/xl10n.dart';
import 'package:face_camera/face_camera.dart';
import 'package:flutter/material.dart';
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';

// ignore:unused_element
const String _tag = "ui/screens/home_screen";

class FacecamScreen extends StatefulWidget {
  const FacecamScreen({
    super.key,
    required this.materialTitle,
    required this.onCapture,
  });

  final String materialTitle;
  final void Function(File? image) onCapture;

  @override
  State<FacecamScreen> createState() => _FacecamScreenState();
}

class _FacecamScreenState extends State<FacecamScreen> {
  late File? _capturedImage = null;
  late bool _isCapturing = false;
  late FaceCameraController _controller = FaceCameraController(
    enableAudio: false,
    autoCapture: false,
    defaultCameraLens: CameraLens.front,
    performanceMode: FaceDetectorMode.accurate,
    onCapture: (image) {
      setState(() {
        this._isCapturing = false;
        this._capturedImage = image;
      });
    },
  );

  @override
  void initState() {
    setState(() => unawaited(this._controller.initialize()));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final AppLocalizations l10n = AppLocalizations.of(context)!;
    return PlatformScaffold(
      appBar: PlatformAppBar(
        material: MaterialAppBarData().onPushedRoute(
          title: widget.materialTitle,
        ),
        cupertino: CupertinoNavigationBarData().onPushedRoute(),
        leading: this._capturedImage != null
            ? TrailingAppBarButton(
                key: const ValueKey(UiValueKeys.facecamDelete),
                text: pl10n(context, PlatformI10nKey.delete),
                onPressed: () {
                  unawaited(this._capturedImage!.delete());
                  setState(() {
                    this._capturedImage = null;
                  });
                },
                color: theme.colorScheme.error,
              )
            : null,
        trailingActions: this._capturedImage != null
            ? [
                TrailingAppBarButton(
                  key: const ValueKey(UiValueKeys.facecamSave),
                  text: pl10n(context, PlatformI10nKey.save),
                  onPressed: () {
                    widget.onCapture(this._capturedImage);
                    Navigator.of(context).pop();
                  },
                ),
              ]
            : null,
      ),
      body: this._capturedImage != null
          ? ColoredBox(
              color: theme.primaryColor,
              child: SizedBox.expand(child: Image.file(this._capturedImage!)),
            )
          : Stack(
              children: [
                SmartFaceCamera(
                  controller: this._controller,
                  indicatorShape: IndicatorShape.circle,
                  showCaptureControl:
                      false, // We use our own button to fix issues
                ),
                Align(
                  alignment: Alignment.bottomCenter,
                  child: Padding(
                    padding: EdgeInsetsGeometry.only(bottom: 70),
                    child: _captureControlWidget(),
                  ),
                ),
              ],
            ),
    );
  }

  Widget _captureControlWidget() {
    return IconButton(
      icon: CircleAvatar(
        radius: 35,
        child: const Padding(
          padding: EdgeInsets.all(8.0),
          child: Icon(Icons.camera_alt, size: 35),
        ),
      ),
      onPressed: () {
        if (this._isCapturing) return;
        setState(() {
          this._isCapturing = true;
        });
        final Face? face = this._controller.value.detectedFace?.face;
        if (this._controller.enableControls &&
            face != null &&
            face.headEulerAngleY! <= 10 &&
            face.headEulerAngleY! >= -10) {
          this._controller.captureImage();
        } else {
          setState(() {
            this._isCapturing = false;
          });
        }
      },
    );
  }

  @override
  void dispose() {
    printd(_tag, "dispose screen");
    super.dispose();
  }
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions