Skip to content

Camera example

A camera is a controller over hardware: every operation takes time, the device can fail on its own, and it has to be closed on purpose. This page builds that controller one decision at a time — state rules, queue policies, the cleanup of the device. It ends on the code of the runnable example/ package: the last fragment of each method below is its code in example/lib/src/camera_controller.dart, comments aside.

The states:

sealed class CameraState {
const CameraState();
}
sealed class NotDisposed extends CameraState {
const NotDisposed();
}
final class Initial extends NotDisposed {
const Initial();
}
final class Preparing extends NotDisposed {
const Preparing();
}
final class Ready extends NotDisposed {
final double zoom;
final Point<double>? focusPoint;
final bool paused;
const Ready({this.zoom = 1, this.focusPoint, this.paused = false});
Ready copyWith({double? zoom, Point<double>? focusPoint, bool? paused}) =>
Ready(
zoom: zoom ?? this.zoom,
focusPoint: focusPoint ?? this.focusPoint,
paused: paused ?? this.paused,
);
}
final class Broken extends NotDisposed {
final Object error;
const Broken(this.error);
}
final class Disposed extends CameraState {
const Disposed();
}

NotDisposed groups every state in which the hardware can still be talked to, and Disposed is the one where it cannot. Broken is where a failure of the hardware lands; it is not called Failed, which is the name of an outcome. In the example every state also prints itself, and that text is what the journals below show; two Ready states with the same fields are equal there as well.

A null passed to copyWith keeps the old value, so copyWith cannot clear focusPoint, where null means automatic focus. The example’s resetFocusPoint publishes a fresh Ready that keeps the current zoom.

The hardware is FakeCameraHardware from the example, and the controller keeps it in its field hw. Every operation takes ten milliseconds and a capture thirty, and a capture returns a Photo. failures makes a named operation fail after its delay, fail reports a failure from outside any job by calling onError, the callback the controller sets, and log records where each operation begins and ends.

The journals below are what the example’s observer prints for this code. They show a job started, finished with its outcome or dropped before it started, an error it reported, a log line, every change of state:, and closed once the controller has closed. A job goes by its key, a value of the example’s CameraKey enum, followed by its describe text where it has one: [setZoom: zoom: 2.0]. A child job’s lines begin with >.

init publishes Preparing, opens the hardware and publishes Ready. It may start only from Initial.

// The first attempt: the first emit leaves the working type.
Job<void> init() => enqueue<Initial, void>(
key: CameraKey.init,
policy: Policy.droppable,
(ctx) async {
ctx.emit(const Preparing());
await ctx.join(hw.open);
ctx.emit(const Ready());
},
);
[init] started
state: Preparing()
[init] finished Cancelled(rules: is not Initial)

The working type is the state the job’s rules require for as long as it runs, and enqueue<Initial, void> requires Initial. The body’s first emit leaves it, the join on the next line is a checkpoint that finds the state outside Initial, and the job ends Cancelled before hw.open is ever called: the hardware log stays empty. The controller is left in Preparing, and the next init() is dropped at its start for the same reason — the camera can no longer be opened at all.

Job<void> init() => enqueue<NotDisposed, void>(
key: CameraKey.init,
policy: Policy.droppable,
canStart: (state) => state is Initial,
(ctx) async {
ctx.emit(const Preparing());
await ctx.join(hw.open);
ctx.emit(const Ready());
},
);

NotDisposed is what the body needs for all its length: Preparing and Ready both belong to it, and the emits stay inside the rules. Where the job may start is a separate question, and canStart answers it. Without it, a second init() on a camera that is already open runs again and opens the hardware a second time. With it, the call ends before it starts:

[init] dropped Cancelled(rules: canStart)

A second init() made while the first one still runs never gets that far: Policy.droppable hands it the job already in flight, and the hardware is opened once.

The hardware can refuse to open while another app holds the camera, and a job can be cancelled while it opens. Either way the controller has to end in a state the camera can be opened from later.

// The first attempt: a cancellation leaves the state at Preparing.
(ctx) async {
ctx.emit(const Preparing());
try {
await ctx.join(hw.open);
} on Object catch (error) {
ctx.emit(Broken(error));
rethrow;
}
ctx.emit(const Ready());
},

A failure lands where it should: the catch publishes Broken, and the job ends Failed. A cancellation passes through the same catch, and there the emit does not publish. On a job that is already cancelled it is a checkpoint, and it throws Cancelled instead:

[init] started
state: Preparing()
[init] finished Cancelled(manual)

join has waited for the opening, so the hardware is open, and the state says it is still opening. Neither way of opening the camera starts from Preparing: init wants Initial, and reopen, the example’s way back, wants Ready or Broken. It is the trap of State after failure or cancellation on the state page, with a device behind the spinner.

Job<void> init() => enqueue<NotDisposed, void>(
key: CameraKey.init,
policy: Policy.droppable,
canStart: (state) => state is Initial,
ifFailed: (state, error, stackTrace) => Broken(error),
ifCancelled: (state, cancelled) => Broken(cancelled),
(ctx) async {
ctx.emit(const Preparing());
await ctx.join(hw.open);
ctx.emit(const Ready());
},
);

The handlers compute the state after the body is over, so a cancellation reaches its own handler instead of a refused emit. A failure and a cancellation both land in Broken, and reopen starts from there:

[init] started
state: Preparing()
[init] error Bad state: camera in use
state: Broken(Bad state: camera in use)
[init] finished Failed(Bad state: camera in use)
[init] started
state: Preparing()
state: Broken(Cancelled(manual))
[init] finished Cancelled(manual)

A job dropped before its start reaches neither handler, so the second init() of the section above still ends in its one dropped line. reopen has the same two handlers.

The hardware reports failures on its own as well, and a report can come while the camera opens. The controller turns it into Broken, and the opening has to leave it there: the camera it was opening is broken.

// The first attempt: Ready is published over a reported failure.
(ctx) async {
ctx.emit(const Preparing());
await ctx.join(hw.open);
ctx.emit(const Ready());
},

This is the body of init under The handlers of enqueue, and the hardware reports a failure while hw.open runs:

[init] started
state: Preparing()
state: Broken(Bad state: cable pulled)
state: Ready(zoom: 1.0, focusPoint: null, paused: false)
[init] finished Done(null)

Broken is a NotDisposed as well, so no rule of the job refuses it. The wait returns as usual, the body publishes Ready over the report, and the job ends Done, which reaches neither handler. The camera says it is ready after its hardware said it is not. reopen does the same with a failure reported while it opens the hardware or restores the zoom.

Job<void> init() => enqueue<NotDisposed, void>(
key: CameraKey.init,
policy: Policy.droppable,
canStart: (state) => state is Initial,
keepWhile: (state) => state is! Broken,
ifFailed: (state, error, stackTrace) => Broken(error),
ifCancelled: (state, cancelled) => Broken(cancelled),
(ctx) async {
ctx.emit(const Preparing());
await ctx.join(hw.open);
ctx.emit(const Ready());
},
);

keepWhile refuses what the working type lets by: an opening does not go on in Broken. The report writes it with externalSetState, the rule refuses it, and the job’s rules cancel the job. join stays with the hardware until it has opened, then throws Cancelled instead of returning, so the emit is never reached:

[init] started
state: Preparing()
state: Broken(Bad state: cable pulled)
[init] finished Cancelled(rules: keepWhile)

A job its rules cancelled leaves the state as it is, and the handlers are disabled at the report itself. A cancellation or a failure of hw.open that comes after it reaches neither of them, and the reported failure stays. It is the rule that Preserving an incompatible external state on the state page gives a load whose connection drops.

reopen starts from Broken, and a failure reported while it closes the hardware is the one it is there to undo. So its rule holds from the Preparing it writes. The body raises the flag the rule reads right before that emit: the emit asks none of the job’s rules, and a report made while Preparing is delivered is refused as well:

Job<void> reopen() {
var opening = false;
return enqueue<NotDisposed, void>(
key: CameraKey.reopen,
policy: Policy.droppable,
canStart: (state) => state is Ready || state is Broken,
keepWhile: (state) => !opening || state is! Broken,
ifFailed: (state, error, stackTrace) => Broken(error),
ifCancelled: (state, cancelled) => Broken(cancelled),
(ctx) async {
final zoom = switch (ctx.state) {
Ready(:final zoom) => zoom,
_ => 1.0,
};
await ctx.run(_closeCameraJob());
opening = true;
ctx.emit(const Preparing());
await ctx.join(hw.open);
await ctx.join(() => hw.setZoom(zoom));
ctx.emit(Ready(zoom: zoom));
},
);
}

A failure reported while it opens the hardware or restores the zoom ends it the way it ends init. A failure reported while it closes the hardware lets it go on. A reopen() called after a report gets a job of its own: the one the report cancelled is still unwinding, and droppable does not hand it out.

A pinch asks for a zoom on every frame, and only the last request matters.

// The first attempt: every zoom reaches the lens.
Job<void> setZoom(double zoom) => enqueue<Ready, void>(
key: CameraKey.setZoom,
describe: () => 'zoom: $zoom',
(ctx) async {
await ctx.join(() => hw.setZoom(zoom));
ctx.emit(ctx.state.copyWith(zoom: zoom));
},
);

Three calls in one turn, setZoom(2), setZoom(3) and setZoom(4), end on Ready(zoom: 4.0, focusPoint: null, paused: false), and so does the version below: by the state the two cannot be told apart. The hardware can. Every request waits in the queue and reaches the lens:

zoom 2.0: begin
zoom 2.0: end
zoom 3.0: begin
zoom 3.0: end
zoom 4.0: begin
zoom 4.0: end
Job<void> setZoom(double zoom) => enqueue<Ready, void>(
key: CameraKey.setZoom,
policy: Policy.keepLatest,
describe: () => 'zoom: $zoom',
canStart: (state) => !state.paused,
(ctx) async {
await ctx.join(() => hw.setZoom(zoom));
ctx.emit(ctx.state.copyWith(zoom: zoom));
},
);

keepLatest queues the new job and then drops the queued one with the same key, so the same three calls reach the hardware once:

[setZoom: zoom: 2.0] dropped Cancelled(replaced)
[setZoom: zoom: 3.0] dropped Cancelled(replaced)
[setZoom: zoom: 4.0] started
state: Ready(zoom: 4.0, focusPoint: null, paused: false)
[setZoom: zoom: 4.0] finished Done(null)

The job that is already running is left to finish. When setZoom(3) and setZoom(4) arrive while the zoom to 2 is in flight, the lens still goes to 2, the request for 3 is dropped, and then it goes to 4. canStart keeps a paused camera from taking the command at all.

Job<Photo> takePhoto() => enqueue<Ready, Photo>(
key: CameraKey.takePhoto,
policy: Policy.droppable,
canStart: (state) => !state.paused,
(ctx) async {
final photo = await ctx.join(hw.capture);
queue.clear();
ctx.log('captured $photo');
return photo;
},
);

A capture takes three times as long as the other operations, and the commands asked for meanwhile wait in the queue. They have nothing to apply to: the frame stays fixed while the capture runs, and after it the user is shown the photo. So once the photo is taken, queue.clear() drops them. A zoom requested five milliseconds into the capture:

[takePhoto] started
[setZoom: zoom: 3.0] dropped Cancelled(manual)
[takePhoto] log captured Photo#1
[takePhoto] finished Done(Photo#1)

Without the clear that zoom runs once the shot is over. queue.clear() leaves the running job alone, and the running job here is the shot itself. It also leaves the jobs that are not cancellable, and this controller queues only one kind of those: a disposal.

dispose() closes the hardware and publishes Disposed, whatever the camera was doing when it was called.

// The first attempt: the disposal waits at the end of the queue.
Job<void> dispose() => enqueue<CameraState, void>(
key: CameraKey.dispose,
policy: Policy.droppable,
cancellable: false,
canStart: (state) => state is! Disposed,
(ctx) async {
if (ctx.state is! Initial) {
await ctx.run(_closeCameraJob());
}
ctx.emit(const Disposed());
},
);
SoloJob<void> _closeCameraJob() => job<NotDisposed, void>(
key: CameraKey.closeCamera,
cancellable: false,
(ctx) async => hw.close(),
);

Called with a zoom in flight and a shot waiting behind it, this disposal takes its place at the end of the queue:

[setZoom: zoom: 2.0] started
state: Ready(zoom: 2.0, focusPoint: null, paused: false)
[setZoom: zoom: 2.0] finished Done(null)
[takePhoto] started
[takePhoto] log captured Photo#1
[takePhoto] finished Done(Photo#1)
[dispose] started
> [closeCamera] started
> [closeCamera] finished Done(null)
state: Disposed()
[dispose] finished Done(null)

Everything in front of it still runs, and the camera takes a photo after it was told to shut down. Its cancellable: false is right: the job may not be cancelled once started, and neither may its child that closes the hardware, so the device is never left half closed.

// The second attempt: a later dispose() reports a cancellation.
Job<void> dispose() {
queue.clear();
current?.cancel();
return enqueue<CameraState, void>(
key: CameraKey.dispose,
policy: Policy.droppable,
cancellable: false,
canStart: (state) => state is! Disposed,
(ctx) async {
if (ctx.state is! Initial) {
await ctx.run(_closeCameraJob());
}
ctx.emit(const Disposed());
},
);
}
[setZoom: zoom: 2.0] started
[takePhoto] dropped Cancelled(manual)
[setZoom: zoom: 2.0] finished Cancelled(manual)
[dispose] started
> [closeCamera] started
> [closeCamera] finished Done(null)
state: Disposed()
[dispose] finished Done(null)

queue.clear() drops what has not started, and the shot never reaches the hardware. current?.cancel() asks the running zoom to stop, and the lens moves to 2 all the same: the zoom waits with join, which lets the job leave only once the operation it started is over. The table at the top of the page Cancellation has each waiting method. What the cancellation saves is the rest of the body: the zoom publishes no state for a camera about to close, and the disposal starts as soon as the hardware is free.

The fault is in a call made after the disposal is over. It queues a job of its own, and canStart drops that job before it starts:

[dispose] dropped Cancelled(rules: canStart)

The camera is disposed, and its caller is told the disposal was cancelled.

Job<void> dispose() {
queue.clear();
current?.cancel();
return enqueue<CameraState, void>(
key: CameraKey.dispose,
policy: Policy.droppable,
cancellable: false,
(ctx) async {
if (ctx.state is Disposed) {
return;
}
if (ctx.state is! Initial) {
await ctx.run(_closeCameraJob());
}
ctx.emit(const Disposed());
},
);
}
[dispose] started
[dispose] finished Done(null)

The same call now starts, finds the camera disposed and ends Done; the hardware log stays empty. canStart is for a job that must not run in some state, and a disposal of a disposed camera may run: it only has nothing to do.

The clear is not forced. A disposal that is still in the queue survives it, because it is not cancellable, and Policy.droppable hands it to the second call. Two dispose() calls in a row get the same job, and both callers see Done. queue.clear(force: true) would drop that job instead, and its caller would get Cancelled(manual) for a camera that was disposed after all.

After the disposal the controller itself is released with close().

// The first attempt: close() drops the queued disposal.
camera.dispose();
await camera.close();
[dispose] dropped Cancelled(closed)
closed

close() goes by where a job is, not by its flag. It waits for the running job, and a job that is not cancellable runs to its end. A job still in the queue never starts. close() ends it with Cancelled(closed), cancellable or not, and whoever awaits it gets that outcome at once, as Cancelling and closing a controller on the cancellation page describes. dispose() queued its job and close() came in the same turn, so the disposal ended in the queue. The hardware log has no close in it. The camera stays open, the state still says Ready, and the controller that could close the camera is closed itself.

// The second attempt: a failed disposal goes to the zone.
camera.dispose();
await camera.close(mode: SoloCloseMode.drain);

SoloCloseMode.drain runs the queue before closing, and the disposal with it: the hardware closes and the state becomes Disposed. The fault shows when the hardware fails to close:

[dispose] started
> [closeCamera] started
> [closeCamera] error Bad state: close timed out
> [closeCamera] finished Failed(Bad state: close timed out)
[dispose] error Bad state: close timed out
[dispose] finished Failed(Bad state: close timed out)
closed

The disposal failed and the camera is still open, but the controller closed right after it, and a second dispose() comes back Cancelled(closed). Nobody reads the outcome of the first one, and no Solo.unansweredHandler is set, so its failure goes to the zone as an unhandled error, the way Handled and unhandled failures on the errors page describes. The failure arrives where nothing can act on it.

final camera = CameraController(FakeCameraHardware());
await camera.init().value;
switch (await camera.dispose().done) {
case Done():
print('disposed');
await camera.close();
case Cancelled(:final reason):
print('cancelled: $reason');
case Failed(:final error):
print('failed: $error');
}

close() stands in the Done case: the disposal is over before it is called, and close() finds nothing to cancel.

Failed is the case that needs handling: a close that fails is not a disposal. ctx.run throws what the child threw, the body never reaches emit(Disposed()), and the state stays where it was. The code above leaves the controller open in that case, so a second dispose() can try again. Cancelled does not come back in this code. A disposal that has started turns every cancellation down; a queued one is taken out only by close() or by a forced removal such as queue.clear(force: true), and nothing here calls either before the outcome arrives. The switch names the case because an outcome is always one of the three. It does come back when close() gets there first, as in the first attempt, or when the controller was closed before dispose() was called.

The hardware can report a failure after the disposal too, and the controller turns every report into Broken. Once the camera is disposed, a report must change nothing: Disposed is the state the controller ends on.

// The first attempt: the listener outlives the disposal.
CameraController(this.hw) : super(const Initial()) {
hw.onError = (error) => externalSetState(Broken(error));
}

The listener is set and never taken off. The camera is disposed and its controller closed as in Awaiting the disposal, and the hardware reports a failure in between, after Disposed and before close():

[dispose] started
> [closeCamera] started
> [closeCamera] finished Done(null)
state: Disposed()
[dispose] finished Done(null)
state: Broken(Bad state: cable pulled)

The disposal decided the final state, and the next report of the hardware replaced it. After close() the same report does not change the state: the state of a closed controller is final, so externalSetState throws a StateError, and it throws into the hardware’s own callback.

// The second attempt: a report before close() still replaces Disposed.
@override
void onClose() => hw.onError = null;

onClose is where externalSetState on the state page stops a source: the engine calls it once, when the last job is over and the controller is about to finish closing. A report that comes after close() now finds no listener, and nothing throws. The report in the journal above came before close(), and this version prints the same journal: the camera is done with its hardware when it is disposed, and the controller closes after that.

Job<void> dispose() {
hw.onError = null;
queue.clear();
current?.cancel();
return enqueue<CameraState, void>(
key: CameraKey.dispose,
policy: Policy.droppable,
cancellable: false,
(ctx) async {
if (ctx.state is Disposed) {
return;
}
if (ctx.state is! Initial) {
await ctx.run(_closeCameraJob());
}
ctx.emit(const Disposed());
},
);
}

The listener goes before anything else, so the disposal decides the final state alone, and a failure reported after it has nobody to tell. The camera stays Disposed. A disposal that fails leaves the listener off as well: until a second dispose() succeeds, the controller does not hear its hardware. onClose stays in the example for a controller that is closed without a disposal.

Terminal window
cd example
dart pub get
dart run bin/main.dart
dart test

bin/main.dart opens the camera, zooms, sets a focus point, starts a shot and disposes of the camera in the middle of it, printing the journal as it goes. The example also has reopen, pause, resume and the focus operations, and its tests exercise each of them.