feat: show a crossed-out camera when another app uses it

This commit is contained in:
Felitendo committed 2026-09-28 14:34:40 +02:00
1 parent ac38638dd4
commit 5eb9ea7438
29 files changed
+1940 -1235

No files matched your search

+12
View File
@@ -117,6 +117,18 @@ void BubbleController::succeeded()
void BubbleController::failed(const QString &reason, qint64 lockout)
{
if (reason == u"camera-busy") {
// A video call, most likely. The daemon starts no scan then, so this
// may come with nothing on screen yet.
if (!enabled()) {
dismiss();
return;
}
setMessage(i18n("Camera in use"));
setPhase(QStringLiteral("busy"));
m_hide.start(int(FailureHoldMs * pace()));
return;
}
if (m_phase == u"hidden") {
return;
}
+2 -1
View File
@@ -17,7 +17,8 @@ class UserConfig;
class BubbleController : public QObject
{
Q_OBJECT
// hidden, scanning, success, failure, lockout
// hidden, scanning, success, failure, lockout, busy (another program has
// the camera)
Q_PROPERTY(QString phase READ phase NOTIFY phaseChanged)
// A short line under the face in the full style: a hint while
// scanning, the reason after a failure.
+1 -1
View File
@@ -47,7 +47,7 @@ QString LockText::text(const QString &phase, const QString &message)
if (phase == u"success") {
return i18n("Face recognized");
}
// failure and lockout say why; hidden says nothing.
// failure, lockout and busy say why; hidden says nothing.
return phase == u"hidden" ? QString() : message;
}
+3 -2
View File
@@ -214,7 +214,7 @@ int lockThroughLogind()
}
// The whole life of a bubble, twice: a face that is recognised after a blink,
// then one that is not.
// then one that is not. Last, a camera another program has.
void scheduleDemo(BubbleController *bubble)
{
const QList<std::pair<int, std::function<void()>>> steps = {
@@ -225,7 +225,8 @@ void scheduleDemo(BubbleController *bubble)
{5600, [bubble] { bubble->scanStarted(); }},
{6200, [bubble] { bubble->faceFound(); }},
{7800, [bubble] { bubble->failed(QStringLiteral("mismatch")); }},
{11000, [] { QCoreApplication::quit(); }},
{11000, [bubble] { bubble->failed(QStringLiteral("camera-busy")); }},
{14000, [] { QCoreApplication::quit(); }},
};
for (const auto &[at, what] : steps) {
QTimer::singleShot(at, bubble, what);
+1 -1
View File
@@ -212,7 +212,7 @@ Item {
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
text: root.bubble ? root.bubble.message : ""
color: root.shownPhase === "failure" || root.shownPhase === "lockout" ? Theme.textDetail : Theme.textSecondary
color: root.shownPhase === "failure" || root.shownPhase === "lockout" || root.shownPhase === "busy" ? Theme.textDetail : Theme.textSecondary
font.pixelSize: 13
font.weight: Font.Medium
opacity: full.hasMessage ? 1 : 0
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// A video camera with a line through it: another program has the camera, a
// video call most likely. The line strikes through once the camera shows.
import QtQuick
import QtQuick.Shapes
Item {
id: root
property color color: Theme.textPrimary
// What the line cuts out of the camera: the island behind it.
property color background: Theme.panel
property bool shown: false
property real pace: 1
readonly property real u: width / 100
// How far the line has come, 0 to 1.
property real strike: 0
onShownChanged: {
if (shown) {
striking.restart()
} else {
striking.stop()
strike = 0
}
}
SequentialAnimation {
id: striking
PropertyAction { target: root; property: "strike"; value: 0 }
PauseAnimation { duration: 120 * root.pace }
NumberAnimation { target: root; property: "strike"; to: 1; duration: 320 * root.pace; easing.type: Easing.OutCubic }
}
readonly property point lineStart: Qt.point(12 * u, 10 * u)
readonly property point lineEnd: Qt.point((12 + 76 * strike) * u, (10 + 80 * strike) * u)
// Body
Rectangle {
x: 4 * root.u
y: 24 * root.u
width: 62 * root.u
height: 52 * root.u
radius: 12 * root.u
color: root.color
}
// Lens, its corners rounded by a stroke of the same colour
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: root.color
strokeColor: root.color
strokeWidth: 6 * root.u
joinStyle: ShapePath.RoundJoin
startX: 72 * root.u; startY: 42 * root.u
PathLine { x: 94 * root.u; y: 29 * root.u }
PathLine { x: 94 * root.u; y: 71 * root.u }
PathLine { x: 72 * root.u; y: 58 * root.u }
PathLine { x: 72 * root.u; y: 42 * root.u }
}
}
// The line, with a gap round it so it reads on the filled camera
Shape {
anchors.fill: parent
visible: root.strike > 0.01
preferredRendererType: Shape.CurveRenderer
ShapePath {
strokeColor: root.background
strokeWidth: 24 * root.u
fillColor: "transparent"
capStyle: ShapePath.RoundCap
startX: root.lineStart.x; startY: root.lineStart.y
PathLine { x: root.lineEnd.x; y: root.lineEnd.y }
}
ShapePath {
strokeColor: root.color
strokeWidth: 9 * root.u
fillColor: "transparent"
capStyle: ShapePath.RoundCap
startX: root.lineStart.x; startY: root.lineStart.y
PathLine { x: root.lineEnd.x; y: root.lineEnd.y }
}
}
}
+17 -2
View File
@@ -11,6 +11,8 @@
// success the rings (below), then a tick in the one that is left
// failure it turns red and shakes its head, as on iOS
// lockout a lock instead of a face
// busy a camera with a line through it: another program has the
// camera
//
// The rings are Face ID's in the Dynamic Island: the face gives way to two
// crossed rings that tumble in 3D with a soft glow, slow down, and land flat
@@ -352,8 +354,8 @@ Item {
id: face
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Gives way to the rings, and to the lock.
property real shown: root.mode === "lockout" ? 0 : 1
// Gives way to the rings, the lock and the camera.
property real shown: root.mode === "lockout" || root.mode === "busy" ? 0 : 1
Behavior on shown { NumberAnimation { duration: 180 * root.pace } }
opacity: shown * (1 - root.faceGone)
scale: 1 - 0.2 * root.faceGone
@@ -418,5 +420,18 @@ Item {
Behavior on opacity { NumberAnimation { duration: 200 * root.pace } }
Behavior on scale { NumberAnimation { duration: 260 * root.pace; easing.type: Easing.OutBack } }
}
CameraGlyph {
anchors.centerIn: parent
width: parent.width * 0.5
height: width
pace: root.pace
color: root.color
shown: root.mode === "busy"
opacity: shown ? 1 : 0
scale: shown ? 1 : 0.7
Behavior on opacity { NumberAnimation { duration: 200 * root.pace } }
Behavior on scale { NumberAnimation { duration: 260 * root.pace; easing.type: Easing.OutBack } }
}
}
}
+32
View File
@@ -15,6 +15,7 @@
#include <unistd.h>
#include <algorithm>
#include <cerrno>
#include <thread>
using namespace std::chrono;
@@ -105,6 +106,24 @@ bool probe(const QString &path, CameraInfo *info)
}
return ok;
}
// A program that streams holds the camera's buffers. Asking for none is how
// V4L2 lets a program check without taking them: the driver says EBUSY when
// they belong to somebody else.
bool busy(const QString &path)
{
const int fd = ::open(QFile::encodeName(path).constData(), O_RDWR | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) {
return errno == EBUSY;
}
v4l2_requestbuffers req{};
req.count = 0;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
const bool result = ::ioctl(fd, VIDIOC_REQBUFS, &req) != 0 && errno == EBUSY;
::close(fd);
return result;
}
} // namespace
Camera::Camera() = default;
@@ -142,6 +161,15 @@ QString Camera::autoPath()
return cameras.isEmpty() ? QString() : cameras.first().path;
}
bool Camera::inUse(const QString &spec)
{
if (spec.startsWith(u"file:") || spec.startsWith(u"images:")) {
return false;
}
const QString path = spec.isEmpty() || spec == u"auto" ? autoPath() : spec;
return !path.isEmpty() && busy(path);
}
bool Camera::open(const QString &spec, QString *error)
{
close();
@@ -178,6 +206,10 @@ bool Camera::open(const QString &spec, QString *error)
*error = QStringLiteral("%1 is not a camera").arg(path);
return false;
}
if (busy(path)) {
*error = QStringLiteral("%1 is in use by another program").arg(path);
return false;
}
if (!m_capture.open(QFile::encodeName(path).toStdString(), cv::CAP_V4L2) || !m_capture.isOpened()) {
*error = QStringLiteral("cannot open %1 (in use by another program?)").arg(path);
+4
View File
@@ -55,6 +55,10 @@ public:
// What "auto" means on this machine: the first colour camera, else the
// first camera at all.
static QString autoPath();
// Whether another program streams from the camera right now, a video
// call for example. Asked without starting it, so the light stays off.
// Never true for a video file or pictures.
static bool inUse(const QString &spec);
private:
bool openImages(const QString &dir, QString *error);
+4 -1
View File
@@ -74,7 +74,10 @@ void ScanJob::run()
Camera camera;
QString error;
if (!camera.open(m_settings.camera, &error)) {
finish(false, QStringLiteral("camera"), {{QStringLiteral("message"), error}});
// The daemon checks for a busy camera before it starts a scan, but a
// video call can take it in between.
const QString reason = Camera::inUse(m_settings.camera) ? QStringLiteral("camera-busy") : QStringLiteral("camera");
finish(false, reason, {{QStringLiteral("message"), error}});
return;
}
Q_EMIT event({{QStringLiteral("event"), QStringLiteral("started")}, {QStringLiteral("camera"), camera.description()}});
+15 -1
View File
@@ -479,12 +479,26 @@ void Server::handleVerify(Client *client, const QJsonObject &request)
return;
}
auto *job = new ScanJob(&m_vision, uid, s, faces, purpose, request.value(u"verbose").toBool());
// The lock screen's agent draws its own scans. Everybody else's it has
// to be told about.
if (purpose != u"unlock") {
m_agentLink = new AgentLink(uid, this);
}
// Another program has the camera, a video call most likely. No scan
// starts: the bubble shows a camera with a line through it, and the
// password is asked for at once.
if (Camera::inUse(s.camera)) {
qInfo("scan for %s (%s): the camera is in use", qPrintable(System::nameOf(uid)), qPrintable(purpose));
broadcast(uid, {{QStringLiteral("event"), QStringLiteral("scan")},
{QStringLiteral("state"), QStringLiteral("failure")},
{QStringLiteral("reason"), QStringLiteral("camera-busy")},
{QStringLiteral("purpose"), purpose}});
fail(client, QStringLiteral("camera-busy"));
return;
}
auto *job = new ScanJob(&m_vision, uid, s, faces, purpose, request.value(u"verbose").toBool());
broadcast(uid, {{QStringLiteral("event"), QStringLiteral("scan")}, {QStringLiteral("state"), QStringLiteral("start")}, {QStringLiteral("purpose"), purpose}});
startJob(job, client);
}
+1
View File
@@ -108,6 +108,7 @@ fu_reason_text() {
lockout) fu_msg "Face unlock is paused after too many tries. Unlock once with your password." ;;
not-enrolled) fu_msg "No face is set up yet." ;;
camera) fu_msg "The camera could not be used." ;;
camera-busy) fu_msg "Another app is using the camera." ;;
models) fu_msg "The recognition models are missing. Reinstall the package." ;;
lid-closed) fu_msg "The lid is closed." ;;
busy) fu_msg "The camera is busy with another scan." ;;