feat: show a crossed-out camera when another app uses it
This commit is contained in:
1 parent
ac38638dd4
commit
5eb9ea7438
29 files changed
+1940
-1235
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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." ;;
|
||||
|
||||
Reference in new issue
Block a user