Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 112 additions & 5 deletions Features/PhysicalAssets/LiveCameraScanner.razor
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,29 @@
@if (!_isStreaming && !_isFrozen)
{
<div class="viewfinder-placeholder">
<p class="mb-3">Camera is inactive.</p>
@if (_support is { HasMediaDevices: false })
{
@*
Safari withholds the camera API entirely on a plain http://
origin, so there is nothing here for a button to ask for.
Say what would make it work instead of offering a dead tap.
*@
<p class="mb-2">This browser will not share a camera over an insecure connection.</p>
<p class="permission-help mb-0">
Open Money Mirror over <code>https://</code>, or on <code>localhost</code>,
and the camera becomes available.
</p>
}
else
{
<p class="mb-2">Camera is inactive.</p>
<p class="permission-help mb-3">
Your browser will ask for permission &mdash; choose <strong>Allow</strong> to start scanning.
</p>
<button type="button" class="btn btn-primary" disabled="@Disabled" @onclick="StartCameraAsync" @onclick:stopPropagation="true">
Start live camera
</button>
}
</div>
}

Expand Down Expand Up @@ -82,7 +101,32 @@

@if (_errorMessage is not null)
{
<div class="alert alert-danger mt-2" role="alert">@_errorMessage</div>
<div class="alert alert-danger mt-2" role="alert">
<p class="mb-0">@_errorMessage</p>

@if (_errorCode is "NotAllowedError" or "PermissionDeniedError")
{
@*
Safari remembers a refusal per site and then refuses again
without prompting, so tapping the button a second time cannot
help. The way back is through the browser's own settings.
*@
<ol class="permission-steps">
@if (_support?.IsIos == true)
{
<li>Tap <strong>AA</strong> at the left of Safari's address bar.</li>
<li>Choose <strong>Website Settings</strong>, then set <strong>Camera</strong> to <strong>Allow</strong>.</li>
<li>Reload this page and tap <strong>Start live camera</strong> again.</li>
}
else
{
<li>Open this page's site permissions, usually the icon at the left of the address bar.</li>
<li>Set <strong>Camera</strong> to <strong>Allow</strong>.</li>
<li>Reload this page and tap <strong>Start live camera</strong> again.</li>
}
</ol>
}
</div>
}

<div class="action-bar">
Expand Down Expand Up @@ -151,22 +195,67 @@
private bool _isModelReady;
private string? _engineDevice;
private string? _errorMessage;
private string? _errorCode;
private string? _statusHint;
private CameraSupport? _support;
private SamSegmentationResult? _lastResult;
private string _cameraFacing = FacingEnvironment;
private SamPromptType _currentPromptMode = SamPromptType.Positive;
private readonly List<SamPointPrompt> _prompts = new();
/// <summary>
/// Asks the browser what it is willing to do before the person taps anything,
/// so an origin with no camera API can say so rather than offer a button that
/// cannot work.
/// </summary>
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}

_cameraModule ??= await JS.InvokeAsync<IJSObjectReference>("import", CameraModulePath);
_support = await _cameraModule.InvokeAsync<CameraSupport>("describeSupport");
StateHasChanged();
}

public async Task StartCameraAsync()
{
_errorMessage = null;
_errorCode = null;

try
{
_cameraModule ??= await JS.InvokeAsync<IJSObjectReference>("import", CameraModulePath);
await _cameraModule.InvokeVoidAsync("startCamera", _video, _cameraFacing);
var result = await _cameraModule.InvokeAsync<CameraStartResult>(
"openCamera",
_video,
_cameraFacing
);

if (!result.Ok)
{
_errorCode = result.Code;
_errorMessage = result.Message;
Logger.LogWarning(
"Camera could not be started ({Code}): {Message}",
result.Code,
result.Message
);
return;
}

_cameraFacing = result.Facing ?? _cameraFacing;
_isStreaming = true;
_isFrozen = false;

// The model is ~40 MB and has nothing to do with which camera is
// running, so a second start should not fetch it again.
if (_isModelReady)
{
return;
}

_statusHint = "Downloading MobileSAM model (~40 MB)...";
StateHasChanged();

Expand Down Expand Up @@ -197,6 +286,7 @@
public async Task SwitchCameraAsync()
{
_errorMessage = null;
_errorCode = null;

if (_cameraModule is null)
{
Expand All @@ -206,8 +296,25 @@
try
{
var nextFacing = _cameraFacing == FacingEnvironment ? FacingUser : FacingEnvironment;
await _cameraModule.InvokeVoidAsync("switchCamera", _video, _cameraFacing);
_cameraFacing = nextFacing;
var result = await _cameraModule.InvokeAsync<CameraStartResult>(
"flipCamera",
_video,
_cameraFacing
);

if (!result.Ok)
{
// Flipping tears the old stream down before asking for the new
// one, so a refusal here leaves no feed at all - worth explaining
// rather than leaving as a blank stage.
_errorCode = result.Code;
_errorMessage = result.Message;
_isStreaming = false;
_isFrozen = false;
return;
}

_cameraFacing = result.Facing ?? nextFacing;
}
catch (Exception ex)
{
Expand Down
26 changes: 26 additions & 0 deletions Features/PhysicalAssets/LiveCameraScanner.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,32 @@
text-align: center;
}

/* Sits on the dark viewfinder stage, so it borrows the placeholder's colour
and steps back from it rather than choosing one of its own. */
.permission-help {
max-width: 24rem;
font-size: 0.875rem;
line-height: 1.5;
opacity: 0.85;
}

.permission-help code {
font-size: 0.9em;
}

/* Recovery steps live inside the denial alert, which is a light surface - these
inherit its colour, not the stage's. */
.permission-steps {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
font-size: 0.875rem;
line-height: 1.5;
}

.permission-steps li + li {
margin-top: 0.25rem;
}

.action-bar {
display: flex;
flex-wrap: wrap;
Expand Down
25 changes: 25 additions & 0 deletions PhysicalAssets/CameraSupport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace MoneyMirror.PhysicalAssets;

/// <summary>
/// What the visitor's browser will allow before they tap anything, as reported
/// by <c>describeSupport</c> in camera-scanner.js.
/// </summary>
/// <param name="SecureContext">
/// False on a plain http:// origin. Safari withholds the camera API entirely
/// there, so the failure needs explaining rather than retrying.
/// </param>
/// <param name="HasMediaDevices">Whether navigator.mediaDevices.getUserMedia exists at all.</param>
/// <param name="IsIos">Whether to offer iOS-shaped recovery steps when permission is refused.</param>
public record CameraSupport(bool SecureContext, bool HasMediaDevices, bool IsIos);

/// <summary>
/// The outcome of asking the browser for a camera, as reported by
/// <c>openCamera</c> in camera-scanner.js.
/// </summary>
/// <param name="Code">
/// The DOMException name - <c>NotAllowedError</c> for a refusal,
/// <c>NotFoundError</c> for a device with no camera, and so on. Interop would
/// drop this if the browser threw instead of reporting, and a refusal is the one
/// failure the person can actually do something about.
/// </param>
public record CameraStartResult(bool Ok, string? Facing, string? Code, string? Message);
73 changes: 69 additions & 4 deletions tests/MoneyMirror.Tests/PhysicalAssets/LiveCameraScannerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,16 @@ public LiveCameraScannerTests()
_context.Services.AddSingleton<IPossessionImageStorage>(_storage);

_module = _context.JSInterop.SetupModule("./js/camera-scanner.js");
_module.SetupVoid("startCamera", _ => true).SetVoidResult();
_module
.Setup<CameraSupport>("describeSupport", _ => true)
.SetResult(new CameraSupport(SecureContext: true, HasMediaDevices: true, IsIos: true));
_module
.Setup<CameraStartResult>("openCamera", _ => true)
.SetResult(new CameraStartResult(Ok: true, Facing: "environment", null, null));
_module
.Setup<CameraStartResult>("flipCamera", _ => true)
.SetResult(new CameraStartResult(Ok: true, Facing: "user", null, null));
_module.SetupVoid("stopCamera", _ => true).SetVoidResult();
_module.SetupVoid("switchCamera", _ => true).SetVoidResult();
_module.SetupVoid("freezeFrame", _ => true).SetVoidResult();
_module.SetupVoid("unfreezeFrame", _ => true).SetVoidResult();
_module
Expand All @@ -50,7 +57,7 @@ public async Task StartCamera_InitializesEngineAndStream()

await cut.Find("button.btn-primary").ClickAsync(new MouseEventArgs());

_module.VerifyInvoke("startCamera", 1);
_module.VerifyInvoke("openCamera", 1);
Assert.True(_samEngine.InitCalls >= 1);
Assert.Contains("Camera active", cut.Markup);
}
Expand Down Expand Up @@ -128,7 +135,65 @@ public async Task SwitchCamera_CallsJsSwitchCamera()
.Single(b => b.TextContent.Contains("Switch camera"));
await switchButton.ClickAsync(new MouseEventArgs());

_module.VerifyInvoke("switchCamera", 1);
_module.VerifyInvoke("flipCamera", 1);
}

[Fact]
public async Task CameraRefused_ExplainsHowToGrantItBack()
{
using var context = new BunitContext();
context.Services.AddLogging();
context.Services.AddSingleton<ISamSegmentationEngine>(new FakeSamEngine());
context.Services.AddSingleton<IPossessionImageStorage>(new FakeStorage());

var module = context.JSInterop.SetupModule("./js/camera-scanner.js");
module.SetupVoid("stopCamera", _ => true).SetVoidResult();
module
.Setup<CameraSupport>("describeSupport", _ => true)
.SetResult(new CameraSupport(SecureContext: true, HasMediaDevices: true, IsIos: true));
module
.Setup<CameraStartResult>("openCamera", _ => true)
.SetResult(
new CameraStartResult(
Ok: false,
Facing: null,
Code: "NotAllowedError",
Message: "Camera access was denied."
)
);

var cut = context.Render<LiveCameraScanner>();
await cut.Find("button.btn-primary").ClickAsync(new MouseEventArgs());

// Safari refuses again without prompting once it has been told no, so a
// second tap cannot help and the way back has to be spelled out.
Assert.Contains("Camera access was denied.", cut.Markup);
Assert.Contains("Website Settings", cut.Markup);
Assert.DoesNotContain("Camera active", cut.Markup);
}

[Fact]
public void InsecureOrigin_OffersNoCameraButtonAtAll()
{
using var context = new BunitContext();
context.Services.AddLogging();
context.Services.AddSingleton<ISamSegmentationEngine>(new FakeSamEngine());
context.Services.AddSingleton<IPossessionImageStorage>(new FakeStorage());

var module = context.JSInterop.SetupModule("./js/camera-scanner.js");
module.SetupVoid("stopCamera", _ => true).SetVoidResult();
module
.Setup<CameraSupport>("describeSupport", _ => true)
.SetResult(
new CameraSupport(SecureContext: false, HasMediaDevices: false, IsIos: true)
);

var cut = context.Render<LiveCameraScanner>();

// There is no camera API on a plain http:// origin, so a button could do
// nothing but fail.
Assert.DoesNotContain("Start live camera", cut.Markup);
Assert.Contains("insecure connection", cut.Markup);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ public VideoScanPickerTests()
_context.Services.AddSingleton<IAssetValuationService>(_valuation);
_context.Services.AddSingleton<IPhysicalAssetRepository>(_repository);
_context.Services.AddSingleton<ISamSegmentationEngine>(new FakeSamEngine());
// The page also hosts LiveCameraScanner, which asks the browser what it
// supports on first render.
_context.JSInterop.SetupModule("./js/camera-scanner.js")
.Setup<CameraSupport>("describeSupport", _ => true)
.SetResult(new CameraSupport(SecureContext: true, HasMediaDevices: true, IsIos: false));

_module = _context.JSInterop.SetupModule("./js/video-scan.js");
_module.Setup<double[]>("prepare", _ => true).SetResult([0.5, 1.5]);
_module.Setup<string>("frameUrl", _ => true).SetResult("blob:test-frame");
Expand Down
Loading
Loading