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

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions docs/superpowers/specs/2026-09-18-media-sync-program-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,18 @@ hides rows the device did link.
- Reproduction plan: ask the #1625 reporter for a single-row health report;
reproduce on the maintainer's Android phone with limited access, with a
moved file, and across an OS re-index.
- Decided 2026-09-23 while planning: the actions appear in the full-screen
viewer and the media info panel, not on grid tiles, which show a distinct
"Not in your allowed photos" placeholder; "Choose photo again" opens the
system's limited-selection sheet (`PhotoManager.presentLimited`) and the
row keeps its link; a lost content-URI grant on the linking device searches
the library by the metadata tiers (`AssetResolutionService.findInLibrary`,
which needs no stored asset id) and is `accessDenied` if nothing matches;
resolution reads permission through a new, non-prompting
`PhotoPickerService.currentPermission`, so the OS prompt comes only from
the picker and "Allow full access". A gallery query that throws is
`accessDenied` too, since `unavailable` read as `notFound` on the linking
device. The PR refs #1625 rather than closing it (section 10).

### 6.4 #425

Expand Down
67 changes: 58 additions & 9 deletions lib/features/media/data/resolvers/local_file_resolver.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:ui' show Size;

import 'package:flutter/services.dart' show PlatformException;
import 'package:submersion/core/models/log_entry.dart';
import 'package:submersion/core/services/logger_service.dart';
import 'package:submersion/features/media/data/resolvers/media_fetch_gate.dart';
Expand Down Expand Up @@ -68,12 +69,16 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe {
bool Function()? usesSecurityScopedBookmarks,
Future<String?> Function()? localDeviceId,
Future<String?> Function(String deviceId)? deviceLabel,
bool Function()? readsContentUris,
Future<MediaSourceData?> Function(MediaItem item)? findInLibrary,
}) : _bookmarkStorage = bookmarkStorage,
_platform = platform,
_exifExtractor = exifExtractor,
_videoThumbnails = videoThumbnails,
_localDeviceId = localDeviceId,
_deviceLabel = deviceLabel,
_readsContentUris = readsContentUris ?? (() => Platform.isAndroid),
_findInLibrary = findInLibrary,
_volumeOnline = (volumeStatus ?? VolumeStatus()).newExpiringProbe(
ttl: volumeProbeTtl,
clock: clock,
Expand Down Expand Up @@ -122,6 +127,16 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe {
/// placeholder. Consulted only on a foreign-origin miss.
final Future<String?> Function(String deviceId)? _deviceLabel;

/// Whether this host links files by content URI (Android). Injectable so
/// the branch runs in the test shards, which run on Linux; production
/// always gets the real check.
final bool Function() _readsContentUris;

/// Searches the photo library for a file whose content URI stopped
/// reading, by the metadata tiers (spec 6.3). Null when there is no
/// library to search; answers null when the search finds nothing.
final Future<MediaSourceData?> Function(MediaItem item)? _findInLibrary;

/// [_localDeviceId]'s answer, memoized once it succeeds. A failed fetch
/// (no database open yet) is not cached, so the next resolution asks again.
String? _knownDeviceId;
Expand Down Expand Up @@ -307,23 +322,24 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe {
return const UnavailableData(kind: UnavailableKind.notFound);
}

if (Platform.isAndroid) {
// coverage:ignore-start
// Android-only URI-bytes branch; test suite runs on macOS hosts so the
// `if` evaluates false. Behaviour mirrored by the iOS/macOS
// bookmark-bytes branch below, which is unit-tested.
if (_readsContentUris()) {
try {
final bytes = await _platform.readUriBytes(ref);
return BytesData(bytes: bytes, servedFrom: ServedFrom.localDisk);
} catch (e, st) {
} on Object catch (e, st) {
// The native handler reports a SecurityException, the read grant
// being gone, as PERMISSION_DENIED; anything else as READ_FAILED.
final grantLost =
e is PlatformException && e.code == 'PERMISSION_DENIED';
_log.warning(
'readUriBytes failed for item ${item.id}',
grantLost
? 'Read grant lost for item ${item.id}'
: 'readUriBytes failed for item ${item.id}',
error: e,
stackTrace: st,
);
return const UnavailableData(kind: UnavailableKind.notFound);
return _afterFailedUriRead(item, grantLost: grantLost);
}
// coverage:ignore-end
}

if (_usesSecurityScopedBookmarks()) {
Expand Down Expand Up @@ -356,6 +372,33 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe {
return const UnavailableData(kind: UnavailableKind.notFound);
}

/// A content URI that did not read, on this device (spec 6.3). Another
/// device's URI never had a grant here, so it is left to [resolve]'s
/// origin rule. Otherwise the library is searched by metadata before
/// anything is decided: a re-indexed or moved photo is usually still
/// there. A lost grant the search cannot recover is inconclusive, since
/// the file may be exactly where it was.
Future<MediaSourceData> _afterFailedUriRead(
MediaItem item, {
required bool grantLost,
}) async {
if (await _importedElsewhere(item)) {
return const UnavailableData(kind: UnavailableKind.notFound);
}
final search = _findInLibrary;
if (search != null) {
try {
final found = await search(item);
if (found != null) return found;
} on Object catch (e) {
_log.warning('Library search for item ${item.id} failed', error: e);
}
}
return UnavailableData(
kind: grantLost ? UnavailableKind.accessDenied : UnavailableKind.notFound,
);
}

/// [_volumeOnline], with a probe that itself failed treated as online.
///
/// The probe is a filesystem call and can throw on the exact mounts it
Expand Down Expand Up @@ -520,6 +563,12 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe {
if (data.kind == UnavailableKind.stillFetching) {
return VerifyResult.transientError;
}
// A lost read grant the library search could not recover: the file may
// be exactly where it was, and notFound here would let the sweep orphan
// it (spec 6.3).
if (data.kind == UnavailableKind.accessDenied) {
return VerifyResult.accessDenied;
}
// A file that is present but unreadable (sandbox denial, revoked
// permission) is not a dead pointer: the bytes are still on disk and a
// re-grant restores access. Reporting notFound here would let the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,36 @@ class PlatformGalleryResolver implements MediaSourceResolver {
// and collapsing the two would report "your photo is gone" for what is
// really "let me look at your photos".
if (resolution.status == ResolutionStatus.accessDenied) {
return const UnavailableData(kind: UnavailableKind.accessDenied);
return UnavailableData(
kind: UnavailableKind.accessDenied,
limitedAccess: resolution.limitedAccess,
);
Comment thread
ericgriffin marked this conversation as resolved.
}
final resolvedId = resolution.localAssetId;
if (resolvedId == null) return _missing(item);
final bytes = await _reader.originBytes(resolvedId);
if (bytes == null) return _missing(item);
return BytesData(bytes: bytes, servedFrom: ServedFrom.platformGallery);
if (bytes != null) {
return BytesData(bytes: bytes, servedFrom: ServedFrom.platformGallery);
}
// A cached mapping is trusted without re-proving it, so the photo can
// stop reading under it: dropped from a limited selection, or
// re-indexed. Search again before calling it gone (spec 6.3), and keep
// an inconclusive answer, which would otherwise read as notFound here.
final again = await _resolutionService.reresolve(item);
if (again.status == ResolutionStatus.accessDenied) {
return UnavailableData(
kind: UnavailableKind.accessDenied,
limitedAccess: again.limitedAccess,
);
}
final newId = again.localAssetId;
if (newId != null && newId != resolvedId) {
final found = await _reader.originBytes(newId);
if (found != null) {
return BytesData(bytes: found, servedFrom: ServedFrom.platformGallery);
}
}
return _missing(item);
}

@override
Expand Down Expand Up @@ -191,9 +214,12 @@ class PlatformGalleryResolver implements MediaSourceResolver {
// Load-bearing: grid tiles call resolveThumbnail, so without this every
// tile on a permission-revoked device reports notFound and the
// reconciler would orphan the whole library.
final status = (await _resolutionService.resolveAssetId(item)).status;
if (status == ResolutionStatus.accessDenied) {
return const UnavailableData(kind: UnavailableKind.accessDenied);
final again = await _resolutionService.resolveAssetId(item);
if (again.status == ResolutionStatus.accessDenied) {
return UnavailableData(
kind: UnavailableKind.accessDenied,
limitedAccess: again.limitedAccess,
);
}
return _missing(item);
}
Expand Down Expand Up @@ -252,6 +278,19 @@ class PlatformGalleryResolver implements MediaSourceResolver {
if (resolvedId != null && await _reader.exists(resolvedId)) {
return VerifyResult.available;
}
// A cached mapping whose asset no longer exists is searched again, as
// in resolve: an inconclusive search must not become the orphaning
// verdict (spec 6.3).
if (resolvedId != null) {
final again = await _resolutionService.reresolve(item);
if (again.status == ResolutionStatus.accessDenied) {
return VerifyResult.accessDenied;
}
final newId = again.localAssetId;
if (newId != null && newId != resolvedId && await _reader.exists(newId)) {
return VerifyResult.available;
}
}
return await _linkedHere(item)
? VerifyResult.notFound
: VerifyResult.fromOtherDevice;
Expand Down
Loading
Loading