Skip to content
1 change: 0 additions & 1 deletion lib/config/platform_policy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ class PlatformPolicy {
desktopOnly: {
PlatformFeature.exportFiles,
PlatformFeature.importFiles,
PlatformFeature.screenshot,
PlatformFeature.videoExport,
PlatformFeature.fileDrop,
},
Expand Down
81 changes: 50 additions & 31 deletions lib/providers/collab/cloud_media_cache_provider.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
Expand Down Expand Up @@ -39,6 +40,40 @@ class CloudMediaCacheState {
}
}

class CloudImageDownloadException implements Exception {
const CloudImageDownloadException(this.statusCode);
final int statusCode;

@override
String toString() => 'Failed to download image ($statusCode).';
}

/// How long one image download may take before it counts as failed.
const cloudImageDownloadTimeout = Duration(seconds: 60);

/// Downloads a cloud image's bytes from [url]. A signed URL the host refuses
/// (401, 403, or 404: it expired) is swapped once for [freshUrl] from the
/// server. Throws [CloudImageDownloadException] when the bytes don't come,
/// and a [TimeoutException] when a request stalls.
Future<Uint8List> downloadCloudImageBytes(
String url, {
required Future<String?> Function() freshUrl,
}) async {
Future<http.Response> get(String url) =>
http.get(Uri.parse(url)).timeout(cloudImageDownloadTimeout);
var response = await get(url);
if (const {401, 403, 404}.contains(response.statusCode)) {
final refreshed = await freshUrl();
if (refreshed != null && refreshed.isNotEmpty) {
response = await get(refreshed);
}
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw CloudImageDownloadException(response.statusCode);
}
return response.bodyBytes;
}

final cloudMediaCacheProvider =
NotifierProvider<CloudMediaCacheNotifier, CloudMediaCacheState>(
CloudMediaCacheNotifier.new,
Expand Down Expand Up @@ -122,32 +157,20 @@ class CloudMediaCacheNotifier extends Notifier<CloudMediaCacheState> {

_markInFlight(asset.publicId, strategyPublicId);
try {
var response = await http.get(Uri.parse(asset.url!));
if (_shouldRefreshSignedUrl(response.statusCode)) {
final linkView = ref.read(shareLinkViewProvider);
final refreshed = await ref
.read(convexStrategyRepositoryProvider)
.getImageAssetUrl(
strategyPublicId: strategyPublicId,
assetPublicId: asset.publicId,
// A signed-out reader's only access is the link they opened.
shareToken: linkView?.strategyPublicId == strategyPublicId
? linkView!.token
: null,
);
if (refreshed != null && refreshed.isNotEmpty) {
response = await http.get(Uri.parse(refreshed));
}
}

if (response.statusCode < 200 || response.statusCode >= 300) {
_recordError(
asset.publicId,
'Failed to cache asset (${response.statusCode}).',
);
return null;
}

final bytes = await downloadCloudImageBytes(
asset.url!,
freshUrl: () {
final linkView = ref.read(shareLinkViewProvider);
return ref.read(convexStrategyRepositoryProvider).getImageAssetUrl(
strategyPublicId: strategyPublicId,
assetPublicId: asset.publicId,
// A signed-out reader's only access is the link they opened.
shareToken: linkView?.strategyPublicId == strategyPublicId
? linkView!.token
: null,
);
},
);
final output = File(
await localAssetPath(
strategyId: strategyId,
Expand All @@ -156,7 +179,7 @@ class CloudMediaCacheNotifier extends Notifier<CloudMediaCacheState> {
),
);
await output.parent.create(recursive: true);
await output.writeAsBytes(response.bodyBytes, flush: true);
await output.writeAsBytes(bytes, flush: true);
_markCached(asset.publicId);
return output;
} catch (error) {
Expand Down Expand Up @@ -190,10 +213,6 @@ class CloudMediaCacheNotifier extends Notifier<CloudMediaCacheState> {
return true;
}

bool _shouldRefreshSignedUrl(int statusCode) {
return statusCode == 401 || statusCode == 403 || statusCode == 404;
}

void resetStrategy(String? strategyPublicId) {
state = CloudMediaCacheState(strategyPublicId: strategyPublicId);
}
Expand Down
58 changes: 43 additions & 15 deletions lib/providers/strategy_image_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ sealed class StrategyImageSource {
// browser fetch the bytes.
webHtmlElementStrategy: WebHtmlElementStrategy.fallback,
),
PendingImageBytes(:final bytes) => MemoryImage(bytes),
ImageBytes(:final bytes) => MemoryImage(bytes),
ImageLoading() || ImageFailed() => null,
};
}
Expand All @@ -42,10 +42,11 @@ final class RemoteImageUrl extends StrategyImageSource {
final String url;
}

/// Bytes this device is still uploading, painted until the cloud URL
/// arrives. Only where images are not files (web).
final class PendingImageBytes extends StrategyImageSource {
const PendingImageBytes(this.bytes);
/// Bytes in memory: an image this device is still uploading, painted until
/// the cloud URL arrives (only where images are not files, on web), or one
/// fetched ahead of an offscreen capture.
final class ImageBytes extends StrategyImageSource {
const ImageBytes(this.bytes);
final Uint8List bytes;
}

Expand All @@ -63,6 +64,16 @@ final class ImageFailed extends StrategyImageSource {

typedef StrategyImageKey = ({String id, String? fileExtension});

/// What each image paints in an offscreen capture, by image id, and null
/// everywhere else.
///
/// A capture renders in its own provider container, which has no live cloud
/// page, upload queue, or pending bytes to resolve an image from. The
/// capture resolves every image it paints before it starts and overrides
/// this provider with the result.
final captureImageSourcesProvider =
Provider<Map<String, StrategyImageSource>?>((ref) => null);

/// Where the bytes for [image] come from, read from a widget's build.
///
/// The file check runs on every build, so a file written or removed while
Expand All @@ -71,12 +82,29 @@ typedef StrategyImageKey = ({String id, String? fileExtension});
StrategyImageSource watchStrategyImageSource(
WidgetRef ref,
StrategyImageKey image,
) =>
_strategyImageSource(ref.watch, image);

/// Where the bytes for [image] come from right now, read once, as the
/// editor would paint it.
StrategyImageSource readStrategyImageSource(
WidgetRef ref,
StrategyImageKey image,
) =>
_strategyImageSource(ref.read, image);

StrategyImageSource _strategyImageSource(
T Function<T>(ProviderListenable<T> provider) watch,
StrategyImageKey image,
) {
final (storageDirectory, source, strategyId) = ref.watch(
final captured = watch(captureImageSourcesProvider);
if (captured != null) return captured[image.id] ?? const ImageFailed();

final (storageDirectory, source, strategyId) = watch(
strategyProvider
.select((s) => (s.storageDirectory, s.source, s.strategyId)),
);
final (assetsLoaded, remoteAsset) = ref.watch(
final (assetsLoaded, remoteAsset) = watch(
remoteEditorSnapshotProvider.select((snapshot) {
final page = snapshot.valueOrNull?.activePage;
return (page != null, page?.assetsById[image.id]);
Expand All @@ -87,8 +115,8 @@ StrategyImageSource watchStrategyImageSource(
// account is known, or while the outbox holds records it could not read,
// the queue cannot rule a queued upload out.
final uploadMayBeQueuedHere = isCloudStrategy &&
(ref.watch(cloudMediaAccountIdProvider) == null ||
ref.watch(
(watch(cloudMediaAccountIdProvider) == null ||
watch(
cloudMediaUploadQueueProvider.select(
(queue) =>
!queue.outboxIsReliable ||
Expand All @@ -97,21 +125,21 @@ StrategyImageSource watchStrategyImageSource(
),
),
));
ref.watch(cloudMediaCacheProvider);
watch(cloudMediaCacheProvider);
// Bytes this browser is still uploading for the signed-in account. They
// only paint when neither the file check below nor the cloud URL has
// anything. Where images are files there are never pending bytes.
Uint8List? pendingBytes;
if (!ref.watch(imageFilesOnDeviceProvider)) {
final accountId = ref.watch(cloudMediaAccountIdProvider);
if (!watch(imageFilesOnDeviceProvider)) {
final accountId = watch(cloudMediaAccountIdProvider);
if (accountId != null && strategyId != null) {
final key = pendingMediaStorageKey((
accountId: accountId,
strategyPublicId: strategyId,
assetPublicId: image.id,
));
pendingBytes = ref
.watch(pendingMediaBytesProvider.select((pending) => pending[key]));
pendingBytes =
watch(pendingMediaBytesProvider.select((pending) => pending[key]));
}
}

Expand Down Expand Up @@ -148,7 +176,7 @@ StrategyImageSource resolveStrategyImageSource({
if (localFilePath != null) return LocalImageFile(localFilePath);
final url = remoteAsset?.url;
if (url != null && url.isNotEmpty) return RemoteImageUrl(url);
if (pendingBytes != null) return PendingImageBytes(pendingBytes);
if (pendingBytes != null) return ImageBytes(pendingBytes);
if (!isCloudStrategy) return const ImageFailed();
if (remoteAsset != null) {
return remoteAsset.uploadStatus == 'failed'
Expand Down
153 changes: 153 additions & 0 deletions lib/screenshot/capture_images.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import 'dart:async';
import 'dart:typed_data';

import 'package:flutter/painting.dart';
import 'package:http/http.dart' as http;
import 'package:icarus/providers/strategy_image_source.dart';

/// An image a capture would paint is not ready: its cloud copy is still
/// loading, or its bytes could not be fetched or decoded. The capture stops
/// rather than save a picture with the image missing.
class CaptureImagesUnavailable implements Exception {
const CaptureImagesUnavailable.stillLoading() : cause = null;
const CaptureImagesUnavailable.fetchFailed(this.cause);

/// Why the image could not be loaded; null while it is still loading.
final Object? cause;

String get userMessage => cause == null
? 'Images on this page are still loading. Try again in a moment.'
: "Couldn't load the images on this page. Check your connection and "
'try again.';

@override
String toString() => cause == null
? 'CaptureImagesUnavailable: an image is still loading'
: 'CaptureImagesUnavailable: $cause';
}

/// Fetches the bytes behind a cloud image's [url].
typedef CaptureImageFetcher = Future<Uint8List> Function(
String imageId,
String url,
);

/// What each image in a capture paints, by image id, decoded and held in
/// the image cache so the capture's first frame already has every picture.
/// Call [release] once the capture is done.
class CaptureImages {
CaptureImages._(this.sources, this._holds);

/// Feeds [captureImageSourcesProvider] in the capture's container.
final Map<String, StrategyImageSource> sources;
final List<_HeldImage> _holds;

void release() {
for (final hold in _holds) {
hold.release();
}
_holds.clear();
}
}

/// Turns where each image's bytes come from, by image id, into what an
/// offscreen capture can paint.
///
/// A capture has no network reads of its own, so cloud URLs are fetched here
/// and painted from memory. Files and bytes already in memory paint as they
/// are, and an image the editor shows as unavailable is captured that way
/// too. Every image that paints is decoded before this returns. Throws
/// [CaptureImagesUnavailable] while an image is still loading, or when a
/// fetch or a decode fails. [checkpoint] runs before each image; whatever it
/// throws stops the work and releases what was held.
Future<CaptureImages> resolveCaptureImages(
Map<String, StrategyImageSource> sources, {
required CaptureImageFetcher fetch,
void Function()? checkpoint,
}) async {
if (sources.values.any((source) => source is ImageLoading)) {
throw const CaptureImagesUnavailable.stillLoading();
}
// Downloads run together, so a page's wait is its slowest image rather
// than the sum of them. They share one client, closed when this returns,
// so a capture that stops early aborts the downloads it no longer needs;
// a download nobody awaits must not surface as an unhandled error.
final client = http.Client();
final downloads = {
for (final MapEntry(key: imageId, value: source) in sources.entries)
if (source case RemoteImageUrl(:final url))
imageId: http.runWithClient(
() => _guard(() => fetch(imageId, url)),
() => client,
)..ignore(),
Comment on lines +79 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keep image client open

When a cloud image’s signed URL has expired, the downloader requests a fresh URL. The first http.get closes the client shared here, so the retry fails even when the fresh URL is valid. The screenshot cannot be saved; video export in PR #230 uses the same image-resolution path. Keep the capture-scoped client open until all requests finish.

Artifacts

Command output from the check

  • The log includes the exact executed before command and its output, showing a successful refreshed-URL capture.

Command output from the check

  • The log includes the exact executed after command and its output, showing the closed-client failure.

Evidence from the check

  • The authored test exercises both capture implementations against the same concurrent-request and signed-URL retry scenario.

Evidence from the check

  • The parent-commit source supplies the pre-change implementation exercised by the test.

View artifacts

T-Rex Ran code and verified through T-Rex

};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
final resolved = <String, StrategyImageSource>{};
final holds = <_HeldImage>[];
try {
for (final MapEntry(key: imageId, value: source) in sources.entries) {
checkpoint?.call();
final download = downloads[imageId];
final paintable = download == null ? source : ImageBytes(await download);
final image = paintable.imageProvider;
if (image != null) {
holds.add(await _guard(() => _HeldImage.decode(image)));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
resolved[imageId] = paintable;
}
checkpoint?.call();
} catch (_) {
for (final hold in holds) {
hold.release();
}
rethrow;
} finally {
client.close();
}
return CaptureImages._(resolved, holds);
}

Future<T> _guard<T>(Future<T> Function() load) async {
try {
return await load();
} catch (error) {
throw CaptureImagesUnavailable.fetchFailed(error);
}
}

/// A decoded image kept listened to, which keeps it among the image cache's
/// live images: a widget asking for the same image finds it decoded, however
/// full the cache gets, until [release].
class _HeldImage {
_HeldImage._(this._stream);

final ImageStream _stream;
late final ImageStreamListener _listener;
bool _attached = false;

/// Completes once [image]'s first frame is decoded.
static Future<_HeldImage> decode(ImageProvider image) {
final hold = _HeldImage._(image.resolve(ImageConfiguration.empty));
final decoded = Completer<_HeldImage>();
hold._listener = ImageStreamListener(
(info, _) {
info.dispose();
if (!decoded.isCompleted) decoded.complete(hold);
},
onError: (Object error, StackTrace? stackTrace) {
hold.release();
if (!decoded.isCompleted) decoded.completeError(error, stackTrace);
},
);
hold._attached = true;
hold._stream.addListener(hold._listener);
return decoded.future;
}

/// Lets go of the image. Safe to call again: a later frame that fails to
/// decode lets go first, and the stream may be gone by the second call.
void release() {
if (!_attached) return;
_attached = false;
_stream.removeListener(_listener);
}
}
Loading
Loading