Offer a new share as a QR code and through the share sheet

The share created page gets "Show QR code", a dialog with the link as a
QR code (black on white, full screen on a phone) that downloads as a PNG
drawn in the browser, with a password reminder for protected shares; and
"Share…", which opens the device's share sheet where there is one. Both
carry only the link.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 11:46:07 +02:00
co-authored by Claude Opus 5
parent fbe358b2e5
commit 1352533667
11 changed files with 416 additions and 10 deletions
+45
View File
@@ -54,6 +54,51 @@ test('a new share\'s link can be copied from the page the upload leads to', func
->assertSee('Copied to the clipboard');
});
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
$share = Share::factory()->withPassword()->create();
$page = ready(visit(route('share.created', $share, false)));
$page->click('[data-test="show-qr-code"]')
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
->assertScript("getComputedStyle(document.querySelector('[data-qr-code]')).backgroundColor === 'rgb(255, 255, 255)'")
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
->assertSee('Recipients also need the password.');
// Record what would be saved instead of saving it.
$page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")');
$page->click('[data-test="download-qr-code"]')
->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'");
// A QR code is roughly a third to a half dark; a blank or failed drawing is not.
$page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")");
$page->assertScript("window.eval('window.png?.type') === 'image/png'")
->assertScript("window.eval('window.png.width') === 1024")
->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6")
->assertNoJavaScriptErrors();
});
test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () {
$share = Share::factory()->create();
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
$page = ready(visit(route('share.created', $share, false)));
// Shown only where the browser has a share sheet.
$page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')");
$page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")");
$page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'");
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")");
$page->wait(0.3)->assertDontSee('The share sheet could not open.');
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")");
$page->assertSee('The share sheet could not open.');
});
test('a recipient on a phone unlocks a password-protected share and sees its files', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]],
+27
View File
@@ -0,0 +1,27 @@
<?php
use App\Models\Share;
use App\Services\QrCodeService;
test('the share created page offers the link as a QR code and through the share sheet', function () {
$share = Share::factory()->create();
$url = route('share.download', $share);
$response = $this->get(route('share.created', $share));
$response->assertOk()
->assertSee($url, false)
->assertSee('data-test="show-qr-code"', false)
->assertSee('data-test="share-sheet"', false)
->assertSee('share-'.$share->token.'.png')
->assertSee('<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">'.app(QrCodeService::class)->svg($url).'</div>', false)
->assertDontSee('Recipients also need the password.');
});
test('the QR code dialog reminds that a protected share also needs its password', function () {
$share = Share::factory()->withPassword()->create();
$this->get(route('share.created', $share))
->assertOk()
->assertSee('Recipients also need the password.');
});
+21
View File
@@ -0,0 +1,21 @@
<?php
use App\Services\QrCodeService;
test('it draws a 1024 pixel QR code as inline SVG, black on white', function () {
$svg = (new QrCodeService)->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP');
expect($svg)->toStartWith('<svg ')
->not->toContain('<?xml')
->toContain('width="1024" height="1024"')
->toContain('fill="#ffffff"')
->toContain('fill="#000000"');
});
test('the same contents give the same code, other contents another', function () {
$service = new QrCodeService;
expect($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'))
->toBe($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'))
->not->toBe($service->svg('https://share.example.com/s/zYxWvUtSrQpOnMlK'));
});