Skip to content

Race condition in the default captureControlBuilder #80

@martin-braun

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();
  }
}

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