From c12e4d3c045e5a14192ee9a3c6c3867795a9b2e4 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:26:22 -0400 Subject: [PATCH 01/12] docs(plan): slice 9, limited photo access and origin re-resolution --- ...09-23-media-sync-phase2-android-limited.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md diff --git a/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md new file mode 100644 index 0000000000..9d1c6482bf --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md @@ -0,0 +1,168 @@ +# Media Sync Slice 9: Limited Photo Access and Origin Re-resolution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On the device that linked a photo, nothing is ever called missing because the app could not see it: limited photo access, a lost Android read grant or a failed gallery query is inconclusive, and a photo that is really in the library is found again by metadata before anything is `notFound`. + +**Architecture:** `AssetResolutionService` reads permission without prompting, and under limited access reports a failed search as `accessDenied` flagged `limitedAccess`, caching nothing. It gains `findInLibrary`, the metadata search without a stored asset id, which `LocalFileResolver` runs when an Android content URI cannot be read; a lost grant that the search does not recover is `accessDenied`. The full-screen viewer and the info panel offer "Allow full access" (system settings) and "Choose photo again" (the system's limited-selection sheet). + +**Tech Stack:** Flutter, Dart, `photo_manager` 3.12.0 (`getPermissionState`, `openSetting`, `presentLimited`), Riverpod, `flutter_test`, mockito, the two-device media harness. + +**Spec:** `docs/superpowers/specs/2026-09-18-media-sync-program-design.md`, section 6.3. Sub-issue #2121, part of #2090. Refs #1625 (spec 10: it closes on the reporter's or the hardware pass's confirmation). Turns scenario S7 green. + +## Global Constraints + +- Owner decisions (2026-09-23): + - The actions appear in the full-screen viewer and the media info panel. Grid tiles show a distinct placeholder with no buttons. + - "Choose photo again" opens the system's limited-selection sheet (`PhotoManager.presentLimited`); the row keeps its link. + - A lost Android content-URI grant on the linking device searches the photo library by the metadata tiers; if nothing matches it is `accessDenied`, never `notFound`. + - Tile resolution reads photo permission without prompting. The OS prompt comes only from the picker and the "Allow full access" button. +- `notFound` is the only verdict that orphans a row, and the orphan flag syncs (spec 3.2). `accessDenied` writes nothing (`reconciledOrphanFlag`, the verifier, the sweep). +- Every background path that consults the photo library checks `supportsGalleryBrowsing` first (spec 9). +- New user-facing strings are added to all 11 ARB files, then `flutter gen-l10n`. +- Repository rules: no em-dashes; no mention of Claude, Claude Code or Anthropic in commits, PRs or comments; no emojis in code; `dart format .` before every commit; paths through `p.join`, never a literal `/tmp`. + +## Facts the design rests on + +- `PhotoPickerServiceMobile.checkPermission` is `PhotoManager.requestPermissionExtend`, which prompts when access was never decided. `currentPermission` (`getPermissionState`) reads without asking but is not on the `PhotoPickerService` interface. `AssetResolutionService._resolveFromGallery` calls `checkPermission` from every cold resolution, including thumbnail renders. +- `_resolveFromGallery` admits `limited` as full access. Under limited access a photo outside the selection is invisible, the search finds nothing, `unresolved` is cached, and on the linking device `PlatformGalleryResolver._missing` turns that into `notFound` (S7). +- A gallery query that throws returns `unavailable` (uncached), which on the linking device is also `notFound`. +- `checkPermission` throws are already logged under `LogCategory.media` (`AssetResolutionService._log`), but without the stack trace. +- `LocalMediaHandler.readUriBytes` (Kotlin) reports a `SecurityException` as `PlatformException(code: 'PERMISSION_DENIED')` and any other failure as `READ_FAILED`. `LocalFileResolver` ignores the code and returns `notFound` for both, behind a hard `Platform.isAndroid` gate that keeps the branch out of every test. +- `LocalFileResolver.verify` maps any unavailable kind it does not list to `VerifyResult.notFound`. +- `UnavailableMediaPlaceholder` has no actions; `MediaItemView` wraps only `stillFetching` in a tap handler. The viewer renders `MediaItemView(item: item, fit: BoxFit.contain)` (`media_viewer_page.dart`). The info panel's `_OriginSection` builds its actions from `OriginFacts.health`, which comes from the stored orphan flag. + +## File Structure + +- Modify `lib/features/media/data/services/photo_picker_service.dart`, `photo_picker_service_mobile.dart`, `photo_picker_service_desktop.dart`, `test/helpers/fake_photo_picker_service.dart`: `currentPermission` on the interface. +- Modify `lib/features/media/data/services/asset_resolution_service.dart`: read-only permission, limited verdict, query failure as `accessDenied`, `findInLibrary`. +- Modify `lib/features/media/domain/value_objects/media_source_data.dart`: `UnavailableData.limitedAccess`. +- Modify `lib/features/media/data/resolvers/platform_gallery_resolver.dart`: pass `limitedAccess` through. +- Modify `lib/features/media/data/resolvers/local_file_resolver.dart` and `lib/features/media/presentation/providers/media_resolver_providers.dart`: the lost-grant search. +- Create `lib/features/media/data/services/photo_access_actions.dart`, `lib/features/media/presentation/providers/photo_access_providers.dart`, `lib/features/media/presentation/widgets/limited_access_actions.dart`. +- Modify `unavailable_media_placeholder.dart`, `media_item_view.dart`, `media_viewer_page.dart`, `media_info_panel.dart`, the 11 ARB files. +- Modify `lib/features/media/presentation/providers/gallery_origin_backfill_provider.dart`: use the interface's `currentPermission`. + +--- + +### Task 1: Resolution never prompts, and a failed query is inconclusive + +**Files:** `photo_picker_service.dart`, `photo_picker_service_mobile.dart`, `photo_picker_service_desktop.dart`, `fake_photo_picker_service.dart`, `asset_resolution_service.dart`, `gallery_origin_backfill_provider.dart`; tests `test/features/media/data/services/asset_resolution_service_test.dart` and a new `test/features/media/data/services/asset_resolution_permission_test.dart`. + +**Interfaces:** Produces `Future PhotoPickerService.currentPermission()`. + +- [ ] **Step 1: Failing tests.** New `asset_resolution_permission_test.dart`, over `FakePhotoPickerService` and an in-memory `LocalAssetCacheRepository`, with the fake counting `checkPermission`/`requestPermission` calls (add `int prompts` to the fake, incremented by both): + - `resolution reads permission and never prompts`: a row whose id does not load, permission `authorized`, one matching candidate; after `resolveAssetId`, `library.prompts == 0`. + - `a gallery query that fails is inconclusive, never unavailable`: the fake's `getAssetsInDateRange` throws (add `Object? queryError`); the result is `ResolutionStatus.accessDenied`, and no cache entry exists. +- [ ] **Step 2: Run, see them fail** (`currentPermission` missing; the query failure is `unavailable`). +- [ ] **Step 3: Implement.** + - Interface: `/// The current photo access, read without asking. ... Future currentPermission();` Mobile: add `@override` to the existing method. Desktop: `@override Future currentPermission() async => PhotoPermissionStatus.authorized;` Fake: returns `permission`. + - `_resolveFromGallery`: `permission = await _photoPickerService.currentPermission();` and the catch becomes `on Object catch (e, stackTrace)` logging `_log.error('Permission check failed for media ${item.id}', error: e, stackTrace: stackTrace)` (still `accessDenied`, still uncached). Comment: a tile render must never show the OS prompt; the picker and the "Allow full access" button are where access is asked for. + - The query catch returns `const ResolutionResult(status: ResolutionStatus.accessDenied)` with a comment: the gallery could not be consulted, which is not evidence of absence, and on the linking device `unavailable` would orphan the row. + - `gallery_origin_backfill_provider.dart`: `permissionStatus: photos.currentPermission` (drop the `is PhotoPickerServiceMobile` branch and its import if unused). + - `asset_resolution_service_test.dart` (mockito): regenerate mocks, and change every `when(mockPicker.checkPermission())` to `currentPermission()`; the existing "permission check throws" test now stubs `currentPermission` to throw. +- [ ] **Step 4: Run** `flutter test test/features/media` **; expect PASS.** +- [ ] **Step 5: Commit** `fix(media): gallery resolution reads photo access without prompting`. + +### Task 2: Limited access is inconclusive (S7) + +**Files:** `media_source_data.dart`, `asset_resolution_service.dart`, `platform_gallery_resolver.dart`, `resolution_scenarios_test.dart`; tests in `asset_resolution_permission_test.dart` and `test/features/media/data/resolvers/platform_gallery_resolver_test.dart`. + +**Interfaces:** Produces `ResolutionResult.limitedAccess` (`bool`, default false) and `UnavailableData.limitedAccess` (`bool`, default false). + +- [ ] **Step 1: Failing tests.** + - `asset_resolution_permission_test.dart`: `under limited access a photo outside the selection is inconclusive`: the asset is in the fake library but in `hiddenFromLimitedAccess`, permission `limited`; result `accessDenied` with `limitedAccess` true; `getCacheEntry` is null. `under limited access a photo in the selection still resolves`: same with the asset visible; result `resolved`. + - `platform_gallery_resolver_test.dart`: its fake resolution service returns `ResolutionResult(status: accessDenied, limitedAccess: true)`; `resolve` returns `UnavailableData` with `kind == accessDenied` and `limitedAccess` true; so does `resolveThumbnail`. + - Remove `skip:` from S7 in `resolution_scenarios_test.dart`, and add `expect((tile.data as UnavailableData).limitedAccess, isTrue);` after its kind assertion. +- [ ] **Step 2: Run, see them fail.** +- [ ] **Step 3: Implement.** + - `ResolutionResult({this.localAssetId, required this.status, this.limitedAccess = false})` with a doc: the gallery was searched through a limited selection, so a miss may be a photo the user did not select. + - `UnavailableData` gains `this.limitedAccess = false` and a doc: only with `accessDenied`; the photo may be in the library but outside what the user allowed. + - `_resolveFromGallery`: after the permission gate, `final limited = permission == PhotoPermissionStatus.limited;`. At both "not found" exits (no candidates, and after tier 3), `if (limited) return const ResolutionResult(status: ResolutionStatus.accessDenied, limitedAccess: true);` before `_cacheUnresolved`, with a comment: a limited selection hides photos the device does have, so a miss is not evidence of absence, and caching it would back off a photo the user can make visible in a moment. + - `PlatformGalleryResolver`: the three `accessDenied` mappings pass `limitedAccess: resolution.limitedAccess` (the thumbnail path keeps the re-derived result, not just its status). +- [ ] **Step 4: Run** `flutter test test/features/media` **; expect PASS, S7 green.** +- [ ] **Step 5: Commit** `fix(media): limited photo access is inconclusive, not missing`. + +### Task 3: A lost Android read grant searches the library first + +**Files:** `asset_resolution_service.dart`, `local_file_resolver.dart`, `media_resolver_providers.dart`; tests in `asset_resolution_permission_test.dart` and a new `test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart`. + +**Interfaces:** Produces `Future AssetResolutionService.findInLibrary(MediaItem item)`; `LocalFileResolver({..., bool Function()? readsContentUris, Future Function(MediaItem item)? findInLibrary})`. + +- [ ] **Step 1: Failing tests.** + - `asset_resolution_permission_test.dart`: `findInLibrary matches a row with no asset id by metadata`: a `localFile` row (no `platformAssetId`, filename and time of an asset in the fake library); result `resolved` with that asset's id. `findInLibrary under limited access is inconclusive`. + - `local_file_resolver_content_uri_test.dart`, with a `LocalMediaPlatform` subclass whose `readUriBytes` throws a given `PlatformException`, `readsContentUris: () => true`, a row with a `bookmarkRef` and no path, `localDeviceId: () async => 'me'`, origin `'me'`: + - `a lost grant the library search recovers serves the photo` (`findInLibrary` returns `BytesData`). + - `a lost grant the search cannot recover is inconclusive` (`PERMISSION_DENIED`, search returns null): `accessDenied`. + - `a failed read the search cannot recover is notFound` (`READ_FAILED`). + - `another device's content URI is not searched` (origin `'peer'`): `fromOtherDevice`, and the search was not called. + - `verify reports a lost grant as accessDenied` (`VerifyResult.accessDenied`). +- [ ] **Step 2: Run, see them fail.** +- [ ] **Step 3: Implement.** + - `AssetResolutionService`: split `_resolveFromGallery` after the original-id probe into `_searchGallery(MediaItem item)` (the permission gate, candidates and tiers). Add `findInLibrary(item)`: the no-gallery guard (`unavailable`), the cache hit and unexpired-backoff checks exactly as `resolveAssetId` has them, the same in-flight de-duplication, then `_searchGallery`. Doc: finds a row that has no usable stored asset id (a file whose read grant was lost) by the metadata tiers alone. + - `LocalFileResolver`: `_readsContentUris = readsContentUris ?? (() => Platform.isAndroid)` replaces the `Platform.isAndroid` gate, and the `coverage:ignore` markers around the branch go. The catch classifies `final grantLost = e is PlatformException && e.code == 'PERMISSION_DENIED';` and returns `await _afterFailedUriRead(item, grantLost: grantLost)`: + ```dart + /// 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 _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.id} failed', error: e); + } + } + return UnavailableData( + kind: grantLost ? UnavailableKind.accessDenied : UnavailableKind.notFound, + ); + } + ``` + - `verify`: `if (data.kind == UnavailableKind.accessDenied) return VerifyResult.accessDenied;` before the local-path check, with a comment. + - `media_resolver_providers.dart`, `LocalFileResolver(...)`: `findInLibrary: (item) async { final r = await ref.read(assetResolutionServiceProvider).findInLibrary(item); final id = r.localAssetId; if (id == null) return null; final bytes = await const PhotoManagerAssetReader().originBytes(id); return bytes == null ? null : BytesData(bytes: bytes, servedFrom: ServedFrom.platformGallery); }`. +- [ ] **Step 4: Run** `flutter test test/features/media` **; expect PASS.** +- [ ] **Step 5: Commit** `fix(media): a lost Android read grant searches the library before anything is missing`. + +### Task 4: "Allow full access" and "Choose photo again" + +**Files:** create `photo_access_actions.dart`, `photo_access_providers.dart`, `limited_access_actions.dart`; modify `unavailable_media_placeholder.dart`, `media_item_view.dart`, `media_viewer_page.dart`, `media_info_panel.dart`, 11 ARB files; tests `test/features/media/presentation/widgets/limited_access_actions_test.dart`, `unavailable_media_placeholder_test.dart`, `media_info_panel_test.dart` (or the panel's existing test file). + +**Interfaces:** `abstract interface class PhotoAccessActions { Future openSettings(); Future chooseMorePhotos(); }`, `PhotoManagerAccessActions` (production); `photoAccessActionsProvider`; `galleryAccessLimitedProvider` (`FutureProvider.autoDispose`, `currentPermission() == limited`, false where `supportsGalleryBrowsing` is false or on error); `LimitedAccessActions({required VoidCallback onChanged})`; `MediaItemView({..., bool showAccessActions = false})`. + +- [ ] **Step 1: ARB keys** in all 11 files (English values; translate for the others): + - `media_unavailablePlaceholder_limitedAccess`: "Not in your allowed photos" + - `media_limitedAccess_allowFullAccess`: "Allow full access" + - `media_limitedAccess_choosePhotoAgain`: "Choose photo again" + Insert in `app_en.arb` alphabetically; in the other files next to `media_unavailablePlaceholder_accessDenied`. Run `flutter gen-l10n`. +- [ ] **Step 2: Failing tests.** + - Placeholder: `accessDenied` with `limitedAccess` shows the limited message; without, the old one. + - `LimitedAccessActions` with a fake `PhotoAccessActions` override: tapping each button calls its action once and then `onChanged`. + - `MediaItemView` with `showAccessActions: true` and a registry answering `UnavailableData(kind: accessDenied, limitedAccess: true)` shows both buttons; with `false` (a grid tile) shows neither. + - Info panel: a `platformGallery` row with `galleryAccessLimitedProvider` overridden to true shows both buttons; false, neither; a `localFile` row, neither. +- [ ] **Step 3: Implement.** + - `PhotoManagerAccessActions`: `openSettings` is `PhotoManager.openSetting()`; `chooseMorePhotos` is `PhotoManager.presentLimited()`. + - `LimitedAccessActions`: a `Wrap` of two `TextButton`s; each awaits its action in a try/catch (a platform failure is logged under media, never thrown) and calls `onChanged` if still mounted. + - Placeholder: `accessDenied` with `limitedAccess` uses `Icons.photo_library_outlined` and the new message. + - `MediaItemView`: a new arm before the generic `UnavailableData()`: `UnavailableData(kind: UnavailableKind.accessDenied, limitedAccess: true) when widget.showAccessActions => Column(mainAxisSize: MainAxisSize.min, children: [Expanded(child: UnavailableMediaPlaceholder(data: data)), LimitedAccessActions(onChanged: _retry)])` (check the parent gives bounded height; the viewer does). + - Viewer: `MediaItemView(item: item, fit: BoxFit.contain, showAccessActions: true)`. + - Info panel `_OriginSection.actions`: `if (origin.sourceType == MediaSourceType.platformGallery && ref.watch(galleryAccessLimitedProvider).value == true) ...[` the two buttons `]`, each invalidating `galleryAccessLimitedProvider` and `mediaByIdProvider(item.id)` after its action. +- [ ] **Step 4: Run** `flutter test test/features/media test/l10n` **(and the l10n staleness check); expect PASS.** +- [ ] **Step 5: Commit** `feat(media): offer full access and the photo selection where a photo is out of reach`. + +### Task 5: Spec notes and verification + +- [ ] Append the decisions above to spec 6.3; add "As executed" notes to this plan. +- [ ] Mutation-check each guard (limited exits, query-failure verdict, `currentPermission` in resolution, the grant-lost classification, the peer-row skip, `verify`'s mapping, the actions' `showAccessActions` gate, the info panel's source-type gate). Each mutation must compile and fail its named test. +- [ ] `dart format .`, `flutter analyze`, `flutter test`, and `test/architecture` explicitly. +- [ ] Commit `docs(spec): record slice 9's decisions in 6.3`. PR body: `Closes #2121`, `Refs #1625`, `Part of #2090`. From 9ed40401fb804be603f9af3c6f89c60ae8e128cf Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:32:35 -0400 Subject: [PATCH 02/12] fix(media): gallery resolution reads photo access without prompting Resolution called checkPermission, which on mobile is a request and shows the OS prompt from a thumbnail render. It now reads the state through a new PhotoPickerService.currentPermission. A gallery query that throws is accessDenied rather than unavailable, which on the linking device read as notFound and orphaned the row, and both failures log their stack trace under the media category. --- .../services/asset_resolution_service.dart | 36 +- .../data/services/photo_picker_service.dart | 8 + .../photo_picker_service_desktop.dart | 4 + .../services/photo_picker_service_mobile.dart | 1 + .../gallery_origin_backfill_provider.dart | 8 +- .../photo_library_candidate_source_test.dart | 3 + .../platform_gallery_resolver_extra_test.dart | 3 + ...form_gallery_resolver_provenance_test.dart | 3 + .../platform_gallery_resolver_test.dart | 3 + .../asset_resolution_permission_test.dart | 98 + .../asset_resolution_service_test.dart | 18 +- .../trip_media_scanner_boundary_test.dart | 3 + .../services/trip_media_scanner_test.dart | 1899 +++++++++-------- .../pages/photo_picker_page_session_test.dart | 3 + .../photo_picker_page_tab_shell_test.dart | 3 + .../photo_picker_providers_test.dart | 3 + .../helpers/trip_scan_actions_test.dart | 6 + test/helpers/fake_photo_picker_service.dart | 22 +- 18 files changed, 1149 insertions(+), 975 deletions(-) create mode 100644 test/features/media/data/services/asset_resolution_permission_test.dart diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index 33ede915b4..f93acb3fdb 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -189,15 +189,24 @@ class AssetResolutionService { // what lets a caller tell "the gallery says no" apart from "the gallery // would not answer". A caller that orphans rows on unavailable would // otherwise mark the whole library missing the moment permission lapses. + // + // Read, never asked: this runs from thumbnail renders, and on a phone + // asking shows the OS prompt, which must come only from the picker or + // the "Allow full access" action (media sync program spec 6.3). final PhotoPermissionStatus permission; try { - permission = await _photoPickerService.checkPermission(); - } catch (e) { - // checkPermission() ultimately hits platform code; treat a - // platform-channel failure like any other gallery failure rather than - // letting it bubble out of resolveAssetId() and break a Riverpod - // provider watching it. - _log.error('Permission check failed for media ${item.id}', error: e); + permission = await _photoPickerService.currentPermission(); + } on Object catch (e, stackTrace) { + // The read ultimately hits platform code; treat a platform-channel + // failure like any other gallery failure rather than letting it bubble + // out of resolveAssetId() and break a Riverpod provider watching it. + // Logged under the media category, with the exception, so a health + // report export shows why the row was inconclusive. + _log.error( + 'Permission check failed for media ${item.id}', + error: e, + stackTrace: stackTrace, + ); return const ResolutionResult(status: ResolutionStatus.accessDenied); } if (permission != PhotoPermissionStatus.authorized && @@ -224,9 +233,16 @@ class AssetResolutionService { for (final asset in found) { byId[asset.id] = asset; } - } catch (e) { - _log.error('Gallery query failed for media ${item.id}', error: e); - return const ResolutionResult(status: ResolutionStatus.unavailable); + } on Object catch (e, stackTrace) { + // The gallery could not be consulted, which says nothing about the + // photo: unavailable would read as notFound on the device that + // linked it and orphan the row everywhere. + _log.error( + 'Gallery query failed for media ${item.id}', + error: e, + stackTrace: stackTrace, + ); + return const ResolutionResult(status: ResolutionStatus.accessDenied); } } final candidates = byId.values.toList(); diff --git a/lib/features/media/data/services/photo_picker_service.dart b/lib/features/media/data/services/photo_picker_service.dart index 3d168acf0d..210c1b3b09 100644 --- a/lib/features/media/data/services/photo_picker_service.dart +++ b/lib/features/media/data/services/photo_picker_service.dart @@ -111,8 +111,16 @@ abstract class PhotoPickerService { Future getFileBytes(String assetId); /// Check the current photo library permission status. + /// + /// On mobile this is a request: it shows the OS prompt when access was + /// never decided. Work the user did not start reads [currentPermission]. Future checkPermission(); + /// The current photo library access, read without asking. What a tile + /// render or a background pass uses, so the OS prompt only ever appears + /// from something the user did (media sync program spec 6.3). + Future currentPermission(); + /// Request photo library permission from the user. /// /// Returns the new permission status after the request. diff --git a/lib/features/media/data/services/photo_picker_service_desktop.dart b/lib/features/media/data/services/photo_picker_service_desktop.dart index 7d4244c995..80f9fc2b47 100644 --- a/lib/features/media/data/services/photo_picker_service_desktop.dart +++ b/lib/features/media/data/services/photo_picker_service_desktop.dart @@ -37,6 +37,10 @@ class PhotoPickerServiceDesktop implements PhotoPickerService { return PhotoPermissionStatus.authorized; } + @override + Future currentPermission() async => + PhotoPermissionStatus.authorized; + @override Future requestPermission() async { // Desktop platforms don't require explicit permission for file access diff --git a/lib/features/media/data/services/photo_picker_service_mobile.dart b/lib/features/media/data/services/photo_picker_service_mobile.dart index c0b5d4fb92..2b26e4e9bd 100644 --- a/lib/features/media/data/services/photo_picker_service_mobile.dart +++ b/lib/features/media/data/services/photo_picker_service_mobile.dart @@ -37,6 +37,7 @@ class PhotoPickerServiceMobile implements PhotoPickerService { /// The current photo access, read without asking. [checkPermission] is a /// request on this platform and shows the OS prompt when access was never /// decided; background work that must not prompt reads this instead. + @override Future currentPermission() async { final status = await pm.PhotoManager.getPermissionState( requestOption: const pm.PermissionRequestOption( diff --git a/lib/features/media/presentation/providers/gallery_origin_backfill_provider.dart b/lib/features/media/presentation/providers/gallery_origin_backfill_provider.dart index 3a0c04c3d5..e17334860b 100644 --- a/lib/features/media/presentation/providers/gallery_origin_backfill_provider.dart +++ b/lib/features/media/presentation/providers/gallery_origin_backfill_provider.dart @@ -5,7 +5,6 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/media/data/services/gallery_asset_reader.dart'; import 'package:submersion/features/media/data/services/gallery_origin_backfill.dart'; -import 'package:submersion/features/media/data/services/photo_picker_service_mobile.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; @@ -28,11 +27,8 @@ final galleryOriginBackfillProvider = Provider Function()>((ref) { mediaRepository: ref.read(mediaRepositoryProvider), reader: const PhotoManagerAssetReader(), photos: photos, - // Read, never asked: this runs after a sync, unasked. The desktop - // service never prompts, so its checkPermission is already a read. - permissionStatus: photos is PhotoPickerServiceMobile - ? photos.currentPermission - : photos.checkPermission, + // Read, never asked: this runs after a sync, unasked. + permissionStatus: photos.currentPermission, deviceId: () => SyncRepository().getDeviceId(), prefs: prefs, ).run(); diff --git a/test/features/media/data/repair/photo_library_candidate_source_test.dart b/test/features/media/data/repair/photo_library_candidate_source_test.dart index accc690728..c89406f646 100644 --- a/test/features/media/data/repair/photo_library_candidate_source_test.dart +++ b/test/features/media/data/repair/photo_library_candidate_source_test.dart @@ -5,6 +5,9 @@ import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; class _FakePicker implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + _FakePicker(this.assets); final List assets; final windows = <(DateTime, DateTime)>[]; diff --git a/test/features/media/data/resolvers/platform_gallery_resolver_extra_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_extra_test.dart index 0f265a264b..2792c69412 100644 --- a/test/features/media/data/resolvers/platform_gallery_resolver_extra_test.dart +++ b/test/features/media/data/resolvers/platform_gallery_resolver_extra_test.dart @@ -12,6 +12,9 @@ import 'package:submersion/features/media/domain/value_objects/media_source_data import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; class _StubPhotoPickerService implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + @override bool get supportsGalleryBrowsing => false; @override diff --git a/test/features/media/data/resolvers/platform_gallery_resolver_provenance_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_provenance_test.dart index 74ea3d2ac4..0f85f70e26 100644 --- a/test/features/media/data/resolvers/platform_gallery_resolver_provenance_test.dart +++ b/test/features/media/data/resolvers/platform_gallery_resolver_provenance_test.dart @@ -19,6 +19,9 @@ import 'package:submersion/features/media/domain/value_objects/media_source_meta // other side: a resolution that produced nothing must never claim a source. class _StubPhotoPickerService implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + @override bool get supportsGalleryBrowsing => false; diff --git a/test/features/media/data/resolvers/platform_gallery_resolver_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_test.dart index 7f796ffdfd..00fa5c428f 100644 --- a/test/features/media/data/resolvers/platform_gallery_resolver_test.dart +++ b/test/features/media/data/resolvers/platform_gallery_resolver_test.dart @@ -17,6 +17,9 @@ import 'package:submersion/features/media/domain/value_objects/verify_result.dar // --------------------------------------------------------------------------- class _StubPhotoPickerService implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + @override bool get supportsGalleryBrowsing => false; diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart new file mode 100644 index 0000000000..009e8baad1 --- /dev/null +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -0,0 +1,98 @@ +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/database/local_cache_database.dart'; +import 'package:submersion/features/media/data/repositories/local_asset_cache_repository.dart'; +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; +import 'package:submersion/features/media/data/services/photo_picker_service.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; + +import '../../../../helpers/fake_photo_picker_service.dart'; + +/// On the device that linked a photo, a search that could not see the whole +/// library is never evidence the photo is gone (media sync program spec +/// 6.3): nothing it reports may become the orphaning notFound verdict. +void main() { + late LocalCacheDatabase cacheDb; + late LocalAssetCacheRepository cache; + late FakePhotoPickerService library; + late AssetResolutionService service; + final taken = DateTime(2026, 7, 1, 10, 30); + + setUp(() { + cacheDb = LocalCacheDatabase(NativeDatabase.memory()); + cache = LocalAssetCacheRepository(database: cacheDb); + library = FakePhotoPickerService(); + service = AssetResolutionService( + cacheRepository: cache, + photoPickerService: library, + ); + }); + + tearDown(() => cacheDb.close()); + + /// A row whose stored id no longer loads (a re-index, or another + /// device's id), carrying the metadata the tiers match on. + MediaItem row() => MediaItem( + id: 'm1', + platformAssetId: 'gone', + originalFilename: 'IMG_0001.JPG', + mediaType: MediaType.photo, + sourceType: MediaSourceType.platformGallery, + width: 4032, + height: 3024, + // Wall-clock-as-UTC, the stored convention. + takenAt: DateTime.utc(2026, 7, 1, 10, 30), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ); + + void addPhoto() => library.add( + FakeGalleryAsset(id: 'B-1', bytes: Uint8List.fromList([1]), takenAt: taken), + ); + + // Resolution runs from thumbnail renders. On a phone, asking for access + // shows the OS prompt, which must come only from the picker or the + // "Allow full access" button. + test('resolution reads permission and never prompts', () async { + addPhoto(); + + final r = await service.resolveAssetId(row()); + + expect(r.localAssetId, 'B-1'); + expect(library.prompts, 0); + }); + + test( + 'a gallery query that fails is inconclusive, never unavailable', + () async { + addPhoto(); + library.queryError = StateError('channel'); + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(await cache.getCacheEntry('m1'), isNull); + }, + ); + + test('a permission read that fails is inconclusive', () async { + final failing = _FailingPermission(); + final r = await AssetResolutionService( + cacheRepository: cache, + photoPickerService: failing, + ).resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(await cache.getCacheEntry('m1'), isNull); + }); +} + +/// A library whose permission read throws, as a platform channel can. +class _FailingPermission extends FakePhotoPickerService { + @override + Future currentPermission() async => + throw StateError('channel'); +} diff --git a/test/features/media/data/services/asset_resolution_service_test.dart b/test/features/media/data/services/asset_resolution_service_test.dart index c7c654835c..d5c3c62db6 100644 --- a/test/features/media/data/services/asset_resolution_service_test.dart +++ b/test/features/media/data/services/asset_resolution_service_test.dart @@ -96,7 +96,7 @@ void main() { mockPicker.getThumbnail('original-asset-id', size: 50), ).thenAnswer((_) async => null); when( - mockPicker.checkPermission(), + mockPicker.currentPermission(), ).thenAnswer((_) async => PhotoPermissionStatus.authorized); // Gallery search returns a match when(mockPicker.getAssetsInDateRange(any, any)).thenAnswer( @@ -174,7 +174,7 @@ void main() { // reading, reopening it on every cancel. when(mockPicker.supportsGalleryBrowsing).thenReturn(false); when( - mockPicker.checkPermission(), + mockPicker.currentPermission(), ).thenAnswer((_) async => PhotoPermissionStatus.authorized); when( mockPicker.getThumbnail(any, size: anyNamed('size')), @@ -219,7 +219,7 @@ void main() { when( mockPicker.getThumbnail('original-asset-id', size: 50), ).thenAnswer((_) async => null); - when(mockPicker.checkPermission()).thenAnswer((_) async => status); + when(mockPicker.currentPermission()).thenAnswer((_) async => status); } test( @@ -293,13 +293,13 @@ void main() { }, ); - // checkPermission() ultimately hits platform code (see - // PhotoPickerServiceMobile.checkPermission()); a platform-channel + // currentPermission() ultimately hits platform code (see + // PhotoPickerServiceMobile.currentPermission()); a platform-channel // exception must not bubble out of resolveAssetId() and break a // Riverpod provider watching it. It should be treated like any other - // gallery failure: log and report unavailable without caching. + // gallery failure: log and report accessDenied without caching. test( - 'returns accessDenied without caching when checkPermission throws', + 'returns accessDenied without caching when the permission read throws', () async { when(mockPicker.supportsGalleryBrowsing).thenReturn(true); when( @@ -310,7 +310,7 @@ void main() { mockPicker.getThumbnail('original-asset-id', size: 50), ).thenAnswer((_) async => null); when( - mockPicker.checkPermission(), + mockPicker.currentPermission(), ).thenThrow(PlatformException(code: 'permission_check_failed')); final result = await service.resolveAssetId(createTestItem()); @@ -343,7 +343,7 @@ void main() { mockPicker.getThumbnail('original-asset-id', size: 50), ).thenAnswer((_) async => null); when( - mockPicker.checkPermission(), + mockPicker.currentPermission(), ).thenAnswer((_) async => PhotoPermissionStatus.authorized); when( mockPicker.getAssetsInDateRange(any, any), diff --git a/test/features/media/data/services/trip_media_scanner_boundary_test.dart b/test/features/media/data/services/trip_media_scanner_boundary_test.dart index 85c33b3bea..41741d57e2 100644 --- a/test/features/media/data/services/trip_media_scanner_boundary_test.dart +++ b/test/features/media/data/services/trip_media_scanner_boundary_test.dart @@ -16,6 +16,9 @@ AssetInfo _asset(String id, DateTime createdAt) => AssetInfo( ); class _Picker implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + _Picker(this.assets); final List assets; diff --git a/test/features/media/data/services/trip_media_scanner_test.dart b/test/features/media/data/services/trip_media_scanner_test.dart index 157942d289..18087e1ec8 100644 --- a/test/features/media/data/services/trip_media_scanner_test.dart +++ b/test/features/media/data/services/trip_media_scanner_test.dart @@ -1,948 +1,951 @@ -import 'dart:typed_data'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:submersion/features/dive_log/domain/entities/dive.dart'; -import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; -import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; -import 'package:submersion/features/media/data/services/linked_gallery_assets.dart'; -import 'package:submersion/features/media/data/services/photo_picker_service.dart'; -import 'package:submersion/features/media/data/services/trip_media_scanner.dart'; -import 'package:submersion/features/media/domain/entities/media_item.dart'; -import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; - -/// Helper to create an AssetInfo for testing. -AssetInfo _testAsset( - String id, { - DateTime? createdAt, - double? latitude, - double? longitude, - AssetType type = AssetType.image, - int? durationSeconds, -}) => AssetInfo( - id: id, - type: type, - createDateTime: createdAt ?? DateTime(2024, 1, 15, 10, 0), - width: 1920, - height: 1080, - durationSeconds: durationSeconds, - latitude: latitude, - longitude: longitude, -); - -/// A gallery row already linked to dive-1. -MediaItem _linkedRow(String id, {required String platformAssetId}) => MediaItem( - id: id, - diveId: 'dive-1', - platformAssetId: platformAssetId, - mediaType: MediaType.photo, - takenAt: DateTime.utc(2024, 1, 15, 10, 30), - createdAt: DateTime.utc(2024, 1, 15), - updatedAt: DateTime.utc(2024, 1, 15), -); - -/// Stub photo picker that records calls and returns the provided -/// [_assets] from `getAssetsInDateRange`. -class _StubPhotoPicker implements PhotoPickerService { - _StubPhotoPicker({ - this.permission = PhotoPermissionStatus.authorized, - List? assets, - }) : _assets = assets ?? const []; - - final PhotoPermissionStatus permission; - final List _assets; - - DateTime? lastStart; - DateTime? lastEnd; - - @override - Future> getAssetsInDateRange( - DateTime start, - DateTime end, - ) async { - lastStart = start; - lastEnd = end; - return _assets; - } - - @override - Future requestPermission() async => permission; - - @override - Future checkPermission() async => permission; - - @override - Future getThumbnail(String assetId, {int size = 200}) async => - null; - - @override - Future getFileBytes(String assetId) async => null; - - @override - Future getFilePath(String assetId) async => null; - - @override - Future getAssetMetadata(String assetId) async => null; - - @override - bool get supportsGalleryBrowsing => true; -} - -void main() { - group('TripMediaScanner', () { - group('matchPhotoToDive', () { - test('returns dive when photo is within dive time range', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - final photoTime = DateTime(2024, 1, 15, 10, 30); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, equals(dive)); - }); - - test('returns null when photo is outside all dive time ranges', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - final photoTime = DateTime(2024, 1, 15, 15, 0); // 4 hours later - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, isNull); - }); - - test('returns dive when photo is within buffer zone before entry', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 20 minutes before entry (within 30 min buffer) - final photoTime = DateTime(2024, 1, 15, 9, 40); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive, - ], bufferMinutes: 30); - - expect(result, equals(dive)); - }); - - test('returns dive when photo is within buffer zone after exit', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 15 minutes after exit (within 30 min buffer) - final photoTime = DateTime(2024, 1, 15, 11, 15); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive, - ], bufferMinutes: 30); - - expect(result, equals(dive)); - }); - - test('returns null when photo is outside buffer zone', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 45 minutes before entry (outside 30 min buffer) - final photoTime = DateTime(2024, 1, 15, 9, 15); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive, - ], bufferMinutes: 30); - - expect(result, isNull); - }); - - test('returns closest dive when photo matches multiple dive buffers', () { - final dive1 = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - final dive2 = Dive( - id: 'dive-2', - dateTime: DateTime(2024, 1, 15, 12, 0), - entryTime: DateTime(2024, 1, 15, 12, 0), - exitTime: DateTime(2024, 1, 15, 13, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 11:45 - 45 min after dive1 exit, 15 min before dive2 entry - final photoTime = DateTime(2024, 1, 15, 11, 45); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive1, - dive2, - ], bufferMinutes: 60); - - // Should return dive2 since it's closer - expect(result, equals(dive2)); - }); - - test( - 'uses dateTime + duration fallback when entry/exit times not set', - () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - bottomTime: const Duration(minutes: 60), - ); - - // Photo during the calculated dive time - final photoTime = DateTime(2024, 1, 15, 10, 30); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, equals(dive)); - }, - ); - - test('returns null for empty dive list', () { - final photoTime = DateTime(2024, 1, 15, 10, 30); - final result = TripMediaScanner.matchPhotoToDive(photoTime, []); - - expect(result, isNull); - }); - - test('prefers exact dive match over buffer match', () { - final dive1 = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - final dive2 = Dive( - id: 'dive-2', - dateTime: DateTime(2024, 1, 15, 10, 30), - entryTime: DateTime(2024, 1, 15, 10, 30), - exitTime: DateTime(2024, 1, 15, 11, 30), - bottomTime: const Duration(minutes: 60), - ); - - // 10:15 - during dive1, within buffer of dive2 - final photoTime = DateTime(2024, 1, 15, 10, 15); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive1, - dive2, - ], bufferMinutes: 30); - - // Should return dive1 since photo was taken during this dive - expect(result, equals(dive1)); - }); - - test('two dives with after-exit buffers, second is closer', () { - // Dive 1: 10-11 → photo at 11:50 is 50min after exit (within 60 buffer) - // Dive 2: 11-12 → photo at 11:50 is during dive2, but use 11:50 - // outside both dives to force after-exit matching: - // Dive 1: 10:00-10:30 → photo 11:00 is 30min after exit - // Dive 2: 11:25-11:55 → photo at 11:00 not in dive2, before-entry 25min - // Actually simpler: have two dives where photo is after both exits. - // Dive 1 ends at 10:00, Dive 2 ends at 10:30. Photo at 11:00. - // Dive 1: 30-min after exit = 60min, Dive 2: 30min after exit. - final dive1 = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 9, 0), - entryTime: DateTime(2024, 1, 15, 9, 0), - exitTime: DateTime(2024, 1, 15, 10, 0), - bottomTime: const Duration(minutes: 60), - ); - final dive2 = Dive( - id: 'dive-2', - dateTime: DateTime(2024, 1, 15, 9, 30), - entryTime: DateTime(2024, 1, 15, 9, 30), - exitTime: DateTime(2024, 1, 15, 10, 30), - bottomTime: const Duration(minutes: 60), - ); - // photo 11:00 = 60min after dive1 exit, 30min after dive2 exit - final photoTime = DateTime(2024, 1, 15, 11, 0); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive1, - dive2, - ], bufferMinutes: 60); - - expect(result, equals(dive2)); - }); - - test( - 'when two dives both contain the photo, picks the closest boundary', - () { - // Dive 1: 10:00-11:00 (photo at 10:30 → 30 min from each boundary) - // Dive 2: 10:25-10:55 (photo at 10:30 → 5 min from entry) - // Both isDuring → second dive should win (smaller distance). - final dive1 = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - final dive2 = Dive( - id: 'dive-2', - dateTime: DateTime(2024, 1, 15, 10, 25), - entryTime: DateTime(2024, 1, 15, 10, 25), - exitTime: DateTime(2024, 1, 15, 10, 55), - bottomTime: const Duration(minutes: 30), - ); - - final photoTime = DateTime(2024, 1, 15, 10, 30); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive1, - dive2, - ]); - - expect(result, equals(dive2)); - }, - ); - - test('default buffer is 30 minutes', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - entryTime: DateTime(2024, 1, 15, 10, 0), - exitTime: DateTime(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 25 minutes before entry (within default 30 min buffer) - final photoTime = DateTime(2024, 1, 15, 9, 35); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, equals(dive)); - }); - }); - - group('matchPhotoToDive with wall-clock-as-UTC dive times', () { - // In production, dive times are stored as wall-clock-as-UTC: - // a dive at 10:00 AM local is DateTime.utc(2024, 1, 15, 10, 0). - // Photo times from photo_manager are local DateTime objects: - // a photo at 10:30 AM local is DateTime(2024, 1, 15, 10, 30). - // The matching must compare wall-clock components, not raw epochs. - - test( - 'matches local photo time to UTC dive time with same wall-clock', - () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // Photo taken at 10:30 AM local (same wall-clock window as dive) - final photoTime = DateTime(2024, 1, 15, 10, 30); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, equals(dive)); - }, - ); - - test('matches local photo in buffer zone before UTC dive entry', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 20 minutes before entry in local time - final photoTime = DateTime(2024, 1, 15, 9, 40); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive, - ], bufferMinutes: 30); - - expect(result, equals(dive)); - }); - - test('matches local photo in buffer zone after UTC dive exit', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 15 minutes after exit in local time - final photoTime = DateTime(2024, 1, 15, 11, 15); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [ - dive, - ], bufferMinutes: 30); - - expect(result, equals(dive)); - }); - - test('rejects local photo outside buffer of UTC dive', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - bottomTime: const Duration(minutes: 60), - ); - - // 4 hours later in local time - final photoTime = DateTime(2024, 1, 15, 15, 0); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, isNull); - }); - - test('uses dateTime + duration fallback with mixed UTC/local', () { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - bottomTime: const Duration(minutes: 60), - ); - - final photoTime = DateTime(2024, 1, 15, 10, 30); - final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); - - expect(result, equals(dive)); - }); - }); - - group('ScanResult', () { - test('totalMatchedPhotos returns sum of all matched photos', () { - final dive1 = Dive( - id: 'dive-1', - dateTime: DateTime(2024, 1, 15, 10, 0), - ); - final dive2 = Dive( - id: 'dive-2', - dateTime: DateTime(2024, 1, 15, 14, 0), - ); - - final result = ScanResult( - matchedByDive: { - dive1: [_testAsset('asset-1'), _testAsset('asset-2')], - dive2: [_testAsset('asset-3')], - }, - unmatched: [_testAsset('asset-4')], - alreadyLinkedCount: 5, - ); - - expect(result.totalMatchedPhotos, equals(3)); - }); - - test('totalNewPhotos returns matched plus unmatched count', () { - final dive = Dive(id: 'dive-1', dateTime: DateTime(2024, 1, 15, 10, 0)); - - final result = ScanResult( - matchedByDive: { - dive: [_testAsset('asset-1'), _testAsset('asset-2')], - }, - unmatched: [_testAsset('asset-3'), _testAsset('asset-4')], - alreadyLinkedCount: 5, - ); - - expect(result.totalNewPhotos, equals(4)); - }); - - test('handles empty matchedByDive', () { - final result = ScanResult( - matchedByDive: const {}, - unmatched: [_testAsset('asset-1')], - alreadyLinkedCount: 0, - ); - - expect(result.totalMatchedPhotos, equals(0)); - expect(result.totalNewPhotos, equals(1)); - }); - - test('handles empty unmatched', () { - final dive = Dive(id: 'dive-1', dateTime: DateTime(2024, 1, 15, 10, 0)); - - final result = ScanResult( - matchedByDive: { - dive: [_testAsset('asset-1')], - }, - unmatched: const [], - alreadyLinkedCount: 3, - ); - - expect(result.totalMatchedPhotos, equals(1)); - expect(result.totalNewPhotos, equals(1)); - }); - }); - - group('toWallClockUtc / wallClockUtcToLocal helpers', () { - test( - 'toWallClockUtc preserves wall-clock components from local DateTime', - () { - final local = DateTime(2024, 6, 1, 10, 30, 45, 123); - final result = TripMediaScanner.toWallClockUtc(local); - expect(result.isUtc, isTrue); - expect(result.year, 2024); - expect(result.month, 6); - expect(result.day, 1); - expect(result.hour, 10); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - }, - ); - - test('toWallClockUtc returns input unchanged when already UTC', () { - final utc = DateTime.utc(2024, 6, 1, 10, 30, 45); - final result = TripMediaScanner.toWallClockUtc(utc); - expect(identical(result, utc), isTrue); - }); - - test('wallClockUtcToLocal preserves wall-clock components', () { - final utc = DateTime.utc(2024, 6, 1, 10, 30, 45, 123); - final result = TripMediaScanner.wallClockUtcToLocal(utc); - expect(result.isUtc, isFalse); - expect(result.year, 2024); - expect(result.month, 6); - expect(result.day, 1); - expect(result.hour, 10); - expect(result.minute, 30); - expect(result.second, 45); - expect(result.millisecond, 123); - }); - - test( - 'wallClockUtcToLocal returns input unchanged when already local', - () { - final local = DateTime(2024, 6, 1, 10, 30, 45); - final result = TripMediaScanner.wallClockUtcToLocal(local); - expect(identical(result, local), isTrue); - }, - ); - }); - - group('scanGalleryForDive', () { - test('returns null when permission is denied', () async { - final picker = _StubPhotoPicker( - permission: PhotoPermissionStatus.denied, - ); - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - ); - final result = await TripMediaScanner.scanGalleryForDive( - dive: dive, - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - expect(result, isNull); - }); - - test( - 'returns assets within the buffer window, filtering already-linked', - () async { - final assets = [ - _testAsset('a-new', createdAt: DateTime(2024, 1, 15, 10, 30)), - _testAsset('a-old', createdAt: DateTime(2024, 1, 15, 10, 45)), - ]; - final picker = _StubPhotoPicker(assets: assets); - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - entryLocation: const GeoPoint(11, 120), - ); - - final result = await TripMediaScanner.scanGalleryForDive( - dive: dive, - linked: [_linkedRow('m-old', platformAssetId: 'a-old')], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - - expect(result, hasLength(1)); - expect(result!.first.id, 'a-new'); - // The picker was called with local-time bounds (UTC bounds were - // adjusted by pre/post buffers and converted via wallClockUtcToLocal). - expect(picker.lastStart, isNotNull); - expect(picker.lastEnd, isNotNull); - expect(picker.lastStart!.isUtc, isFalse); - }, - ); - - test( - 'uses dateTime + duration fallback when entry/exit not set', - () async { - final picker = _StubPhotoPicker( - assets: [ - _testAsset('a1', createdAt: DateTime(2024, 1, 15, 10, 30)), - ], - ); - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - // no entryTime / exitTime / runtime - ); - final result = await TripMediaScanner.scanGalleryForDive( - dive: dive, - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - expect(result, hasLength(1)); - }, - ); - - test('filters a photo linked on another device, which the synced id ' - 'alone cannot recognise (#885)', () async { - final picker = _StubPhotoPicker( - assets: [ - _testAsset('mac-1', createdAt: DateTime(2024, 1, 15, 10, 30)), - _testAsset('mac-2', createdAt: DateTime(2024, 1, 15, 10, 45)), - ], - ); - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - ); - - final result = await TripMediaScanner.scanGalleryForDive( - dive: dive, - linked: [_linkedRow('m1', platformAssetId: 'iphone-1')], - linkedGalleryAssets: LinkedGalleryAssets( - resolve: (item) async => item.id == 'm1' - ? const ResolutionResult( - localAssetId: 'mac-1', - status: ResolutionStatus.resolved, - ) - : const ResolutionResult(status: ResolutionStatus.unavailable), - ), - photoPickerService: picker, - ); - - expect(result!.map((a) => a.id), ['mac-2']); - }); - }); - - group('scanGalleryForTrip', () { - test( - 'counts a burst linked on another device as already linked', - () async { - // Two frames in one second at one size, both linked on the iPhone: - // the resolver cannot say which row is which frame, but between - // them the rows account for both. - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - ); - MediaItem burstRow(String id) => MediaItem( - id: id, - diveId: 'dive-1', - platformAssetId: 'iphone-$id', - mediaType: MediaType.photo, - takenAt: DateTime.utc(2024, 1, 15, 10, 30, 7), - width: 1920, - height: 1080, - createdAt: DateTime.utc(2024, 1, 15), - updatedAt: DateTime.utc(2024, 1, 15), - ); - final picker = _StubPhotoPicker( - assets: [ - _testAsset('mac-1', createdAt: DateTime(2024, 1, 15, 10, 30, 7)), - _testAsset('mac-2', createdAt: DateTime(2024, 1, 15, 10, 30, 7)), - ], - ); - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: [dive], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 16), - linked: [burstRow('m1'), burstRow('m2')], - linkedGalleryAssets: LinkedGalleryAssets( - resolve: (_) async => - const ResolutionResult(status: ResolutionStatus.unavailable), - ), - photoPickerService: picker, - ); - - expect(result!.alreadyLinkedCount, 2); - expect(result.totalNewPhotos, 0); - }, - ); - - test('returns null when permission is denied', () async { - final picker = _StubPhotoPicker( - permission: PhotoPermissionStatus.denied, - ); - final result = await TripMediaScanner.scanGalleryForTrip( - dives: const [], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 17), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - expect(result, isNull); - }); - - test('groups matched assets by dive and surfaces unmatched', () async { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - ); - // a1: during the dive → matched - // a2: outside dive bounds → unmatched - // a3: already linked → filtered out before matching - final assets = [ - _testAsset( - 'a1', - createdAt: DateTime(2024, 1, 15, 10, 30), - latitude: 30.0, - longitude: -120.0, - ), - _testAsset('a2', createdAt: DateTime(2024, 1, 15, 18, 0)), - _testAsset('a3', createdAt: DateTime(2024, 1, 15, 10, 45)), - ]; - final picker = _StubPhotoPicker(assets: assets); - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: [dive], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 16), - linked: [_linkedRow('m3', platformAssetId: 'a3')], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - - expect(result, isNotNull); - expect(result!.alreadyLinkedCount, 1); - expect(result.matchedByDive[dive], hasLength(1)); - expect(result.matchedByDive[dive]!.first.id, 'a1'); - expect(result.unmatched, hasLength(1)); - expect(result.unmatched.first.id, 'a2'); - }); - - test( - 'queries through the full trip end day with timezone slack', - () async { - final picker = _StubPhotoPicker(); - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: const [], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 17), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - - expect(result, isNotNull); - expect(picker.lastStart, DateTime(2024, 1, 14)); - expect(picker.lastEnd, DateTime(2024, 1, 18, 23, 59, 59, 999, 999)); - }, - ); - - test('matches shifted Photos asset using EXIF fallback time', () async { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - ); - final picker = _StubPhotoPicker( - assets: [ - _testAsset('shifted', createdAt: DateTime(2024, 1, 14, 19, 49, 11)), - ], - ); - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: [dive], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 15), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - assetMetadataResolver: (asset) async { - expect(asset.id, 'shifted'); - return MediaSourceMetadata( - takenAt: DateTime.utc(2024, 1, 15, 10, 30), - mimeType: 'image/jpeg', - ); - }, - ); - - expect(result, isNotNull); - expect(result!.matchedByDive[dive], hasLength(1)); - expect(result.matchedByDive[dive]!.single.id, 'shifted'); - expect(result.unmatched, isEmpty); - }); - - test( - 'matches in-trip shifted Photos asset using EXIF fallback time', - () async { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - entryLocation: const GeoPoint(18, -60), - ); - final picker = _StubPhotoPicker( - assets: [ - _testAsset('shifted', createdAt: DateTime(2024, 1, 15, 7, 30)), - ], - ); - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: [dive], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 15), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - assetMetadataResolver: (asset) async { - expect(asset.id, 'shifted'); - return MediaSourceMetadata( - takenAt: DateTime.utc(2024, 1, 15, 10, 30), - mimeType: 'image/jpeg', - ); - }, - ); - - expect(result, isNotNull); - expect(result!.matchedByDive[dive], hasLength(1)); - expect(result.matchedByDive[dive]!.single.id, 'shifted'); - expect(result.unmatched, isEmpty); - }, - ); - - test( - 'matches shifted Photos asset with no loaded dive location', - () async { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - ); - final picker = _StubPhotoPicker( - assets: [ - _testAsset('shifted', createdAt: DateTime(2024, 1, 14, 19, 30)), - ], - ); - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: [dive], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 15), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - assetMetadataResolver: (asset) async { - expect(asset.id, 'shifted'); - return MediaSourceMetadata( - takenAt: DateTime.utc(2024, 1, 15, 10, 30), - mimeType: 'image/jpeg', - ); - }, - ); - - expect(result, isNotNull); - expect(result!.matchedByDive[dive], hasLength(1)); - expect(result.matchedByDive[dive]!.single.id, 'shifted'); - expect(result.unmatched, isEmpty); - }, - ); - - test( - 'does not load metadata for unrelated slack-window assets', - () async { - final dive = Dive( - id: 'dive-1', - dateTime: DateTime.utc(2024, 1, 15, 10, 0), - entryTime: DateTime.utc(2024, 1, 15, 10, 0), - exitTime: DateTime.utc(2024, 1, 15, 11, 0), - entryLocation: const GeoPoint(18, -60), - ); - final picker = _StubPhotoPicker( - assets: [ - _testAsset('unrelated', createdAt: DateTime(2024, 1, 14, 3, 30)), - _testAsset('candidate', createdAt: DateTime(2024, 1, 15, 7, 30)), - ], - ); - final metadataRequests = []; - - final result = await TripMediaScanner.scanGalleryForTrip( - dives: [dive], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 15), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - assetMetadataResolver: (asset) async { - metadataRequests.add(asset.id); - return null; - }, - ); - - expect(result, isNotNull); - expect(metadataRequests, ['candidate']); - }, - ); - - test('handles permission limited (still scans)', () async { - final picker = _StubPhotoPicker( - permission: PhotoPermissionStatus.limited, - ); - final result = await TripMediaScanner.scanGalleryForTrip( - dives: const [], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 17), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - expect(result, isNotNull); - }); - - test('returns empty unmatched when no assets', () async { - final picker = _StubPhotoPicker(); - final result = await TripMediaScanner.scanGalleryForTrip( - dives: const [], - tripStartDate: DateTime.utc(2024, 1, 15), - tripEndDate: DateTime.utc(2024, 1, 17), - linked: const [], - linkedGalleryAssets: const LinkedGalleryAssets(), - photoPickerService: picker, - ); - expect(result, isNotNull); - expect(result!.unmatched, isEmpty); - expect(result.matchedByDive, isEmpty); - }); - }); - }); -} +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/dive_log/domain/entities/dive.dart'; +import 'package:submersion/features/dive_sites/domain/entities/dive_site.dart'; +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; +import 'package:submersion/features/media/data/services/linked_gallery_assets.dart'; +import 'package:submersion/features/media/data/services/photo_picker_service.dart'; +import 'package:submersion/features/media/data/services/trip_media_scanner.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_metadata.dart'; + +/// Helper to create an AssetInfo for testing. +AssetInfo _testAsset( + String id, { + DateTime? createdAt, + double? latitude, + double? longitude, + AssetType type = AssetType.image, + int? durationSeconds, +}) => AssetInfo( + id: id, + type: type, + createDateTime: createdAt ?? DateTime(2024, 1, 15, 10, 0), + width: 1920, + height: 1080, + durationSeconds: durationSeconds, + latitude: latitude, + longitude: longitude, +); + +/// A gallery row already linked to dive-1. +MediaItem _linkedRow(String id, {required String platformAssetId}) => MediaItem( + id: id, + diveId: 'dive-1', + platformAssetId: platformAssetId, + mediaType: MediaType.photo, + takenAt: DateTime.utc(2024, 1, 15, 10, 30), + createdAt: DateTime.utc(2024, 1, 15), + updatedAt: DateTime.utc(2024, 1, 15), +); + +/// Stub photo picker that records calls and returns the provided +/// [_assets] from `getAssetsInDateRange`. +class _StubPhotoPicker implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + + _StubPhotoPicker({ + this.permission = PhotoPermissionStatus.authorized, + List? assets, + }) : _assets = assets ?? const []; + + final PhotoPermissionStatus permission; + final List _assets; + + DateTime? lastStart; + DateTime? lastEnd; + + @override + Future> getAssetsInDateRange( + DateTime start, + DateTime end, + ) async { + lastStart = start; + lastEnd = end; + return _assets; + } + + @override + Future requestPermission() async => permission; + + @override + Future checkPermission() async => permission; + + @override + Future getThumbnail(String assetId, {int size = 200}) async => + null; + + @override + Future getFileBytes(String assetId) async => null; + + @override + Future getFilePath(String assetId) async => null; + + @override + Future getAssetMetadata(String assetId) async => null; + + @override + bool get supportsGalleryBrowsing => true; +} + +void main() { + group('TripMediaScanner', () { + group('matchPhotoToDive', () { + test('returns dive when photo is within dive time range', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + final photoTime = DateTime(2024, 1, 15, 10, 30); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, equals(dive)); + }); + + test('returns null when photo is outside all dive time ranges', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + final photoTime = DateTime(2024, 1, 15, 15, 0); // 4 hours later + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, isNull); + }); + + test('returns dive when photo is within buffer zone before entry', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 20 minutes before entry (within 30 min buffer) + final photoTime = DateTime(2024, 1, 15, 9, 40); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive, + ], bufferMinutes: 30); + + expect(result, equals(dive)); + }); + + test('returns dive when photo is within buffer zone after exit', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 15 minutes after exit (within 30 min buffer) + final photoTime = DateTime(2024, 1, 15, 11, 15); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive, + ], bufferMinutes: 30); + + expect(result, equals(dive)); + }); + + test('returns null when photo is outside buffer zone', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 45 minutes before entry (outside 30 min buffer) + final photoTime = DateTime(2024, 1, 15, 9, 15); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive, + ], bufferMinutes: 30); + + expect(result, isNull); + }); + + test('returns closest dive when photo matches multiple dive buffers', () { + final dive1 = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + final dive2 = Dive( + id: 'dive-2', + dateTime: DateTime(2024, 1, 15, 12, 0), + entryTime: DateTime(2024, 1, 15, 12, 0), + exitTime: DateTime(2024, 1, 15, 13, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 11:45 - 45 min after dive1 exit, 15 min before dive2 entry + final photoTime = DateTime(2024, 1, 15, 11, 45); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive1, + dive2, + ], bufferMinutes: 60); + + // Should return dive2 since it's closer + expect(result, equals(dive2)); + }); + + test( + 'uses dateTime + duration fallback when entry/exit times not set', + () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + bottomTime: const Duration(minutes: 60), + ); + + // Photo during the calculated dive time + final photoTime = DateTime(2024, 1, 15, 10, 30); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, equals(dive)); + }, + ); + + test('returns null for empty dive list', () { + final photoTime = DateTime(2024, 1, 15, 10, 30); + final result = TripMediaScanner.matchPhotoToDive(photoTime, []); + + expect(result, isNull); + }); + + test('prefers exact dive match over buffer match', () { + final dive1 = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + final dive2 = Dive( + id: 'dive-2', + dateTime: DateTime(2024, 1, 15, 10, 30), + entryTime: DateTime(2024, 1, 15, 10, 30), + exitTime: DateTime(2024, 1, 15, 11, 30), + bottomTime: const Duration(minutes: 60), + ); + + // 10:15 - during dive1, within buffer of dive2 + final photoTime = DateTime(2024, 1, 15, 10, 15); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive1, + dive2, + ], bufferMinutes: 30); + + // Should return dive1 since photo was taken during this dive + expect(result, equals(dive1)); + }); + + test('two dives with after-exit buffers, second is closer', () { + // Dive 1: 10-11 → photo at 11:50 is 50min after exit (within 60 buffer) + // Dive 2: 11-12 → photo at 11:50 is during dive2, but use 11:50 + // outside both dives to force after-exit matching: + // Dive 1: 10:00-10:30 → photo 11:00 is 30min after exit + // Dive 2: 11:25-11:55 → photo at 11:00 not in dive2, before-entry 25min + // Actually simpler: have two dives where photo is after both exits. + // Dive 1 ends at 10:00, Dive 2 ends at 10:30. Photo at 11:00. + // Dive 1: 30-min after exit = 60min, Dive 2: 30min after exit. + final dive1 = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 9, 0), + entryTime: DateTime(2024, 1, 15, 9, 0), + exitTime: DateTime(2024, 1, 15, 10, 0), + bottomTime: const Duration(minutes: 60), + ); + final dive2 = Dive( + id: 'dive-2', + dateTime: DateTime(2024, 1, 15, 9, 30), + entryTime: DateTime(2024, 1, 15, 9, 30), + exitTime: DateTime(2024, 1, 15, 10, 30), + bottomTime: const Duration(minutes: 60), + ); + // photo 11:00 = 60min after dive1 exit, 30min after dive2 exit + final photoTime = DateTime(2024, 1, 15, 11, 0); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive1, + dive2, + ], bufferMinutes: 60); + + expect(result, equals(dive2)); + }); + + test( + 'when two dives both contain the photo, picks the closest boundary', + () { + // Dive 1: 10:00-11:00 (photo at 10:30 → 30 min from each boundary) + // Dive 2: 10:25-10:55 (photo at 10:30 → 5 min from entry) + // Both isDuring → second dive should win (smaller distance). + final dive1 = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + final dive2 = Dive( + id: 'dive-2', + dateTime: DateTime(2024, 1, 15, 10, 25), + entryTime: DateTime(2024, 1, 15, 10, 25), + exitTime: DateTime(2024, 1, 15, 10, 55), + bottomTime: const Duration(minutes: 30), + ); + + final photoTime = DateTime(2024, 1, 15, 10, 30); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive1, + dive2, + ]); + + expect(result, equals(dive2)); + }, + ); + + test('default buffer is 30 minutes', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + entryTime: DateTime(2024, 1, 15, 10, 0), + exitTime: DateTime(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 25 minutes before entry (within default 30 min buffer) + final photoTime = DateTime(2024, 1, 15, 9, 35); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, equals(dive)); + }); + }); + + group('matchPhotoToDive with wall-clock-as-UTC dive times', () { + // In production, dive times are stored as wall-clock-as-UTC: + // a dive at 10:00 AM local is DateTime.utc(2024, 1, 15, 10, 0). + // Photo times from photo_manager are local DateTime objects: + // a photo at 10:30 AM local is DateTime(2024, 1, 15, 10, 30). + // The matching must compare wall-clock components, not raw epochs. + + test( + 'matches local photo time to UTC dive time with same wall-clock', + () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // Photo taken at 10:30 AM local (same wall-clock window as dive) + final photoTime = DateTime(2024, 1, 15, 10, 30); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, equals(dive)); + }, + ); + + test('matches local photo in buffer zone before UTC dive entry', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 20 minutes before entry in local time + final photoTime = DateTime(2024, 1, 15, 9, 40); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive, + ], bufferMinutes: 30); + + expect(result, equals(dive)); + }); + + test('matches local photo in buffer zone after UTC dive exit', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 15 minutes after exit in local time + final photoTime = DateTime(2024, 1, 15, 11, 15); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [ + dive, + ], bufferMinutes: 30); + + expect(result, equals(dive)); + }); + + test('rejects local photo outside buffer of UTC dive', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + bottomTime: const Duration(minutes: 60), + ); + + // 4 hours later in local time + final photoTime = DateTime(2024, 1, 15, 15, 0); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, isNull); + }); + + test('uses dateTime + duration fallback with mixed UTC/local', () { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + bottomTime: const Duration(minutes: 60), + ); + + final photoTime = DateTime(2024, 1, 15, 10, 30); + final result = TripMediaScanner.matchPhotoToDive(photoTime, [dive]); + + expect(result, equals(dive)); + }); + }); + + group('ScanResult', () { + test('totalMatchedPhotos returns sum of all matched photos', () { + final dive1 = Dive( + id: 'dive-1', + dateTime: DateTime(2024, 1, 15, 10, 0), + ); + final dive2 = Dive( + id: 'dive-2', + dateTime: DateTime(2024, 1, 15, 14, 0), + ); + + final result = ScanResult( + matchedByDive: { + dive1: [_testAsset('asset-1'), _testAsset('asset-2')], + dive2: [_testAsset('asset-3')], + }, + unmatched: [_testAsset('asset-4')], + alreadyLinkedCount: 5, + ); + + expect(result.totalMatchedPhotos, equals(3)); + }); + + test('totalNewPhotos returns matched plus unmatched count', () { + final dive = Dive(id: 'dive-1', dateTime: DateTime(2024, 1, 15, 10, 0)); + + final result = ScanResult( + matchedByDive: { + dive: [_testAsset('asset-1'), _testAsset('asset-2')], + }, + unmatched: [_testAsset('asset-3'), _testAsset('asset-4')], + alreadyLinkedCount: 5, + ); + + expect(result.totalNewPhotos, equals(4)); + }); + + test('handles empty matchedByDive', () { + final result = ScanResult( + matchedByDive: const {}, + unmatched: [_testAsset('asset-1')], + alreadyLinkedCount: 0, + ); + + expect(result.totalMatchedPhotos, equals(0)); + expect(result.totalNewPhotos, equals(1)); + }); + + test('handles empty unmatched', () { + final dive = Dive(id: 'dive-1', dateTime: DateTime(2024, 1, 15, 10, 0)); + + final result = ScanResult( + matchedByDive: { + dive: [_testAsset('asset-1')], + }, + unmatched: const [], + alreadyLinkedCount: 3, + ); + + expect(result.totalMatchedPhotos, equals(1)); + expect(result.totalNewPhotos, equals(1)); + }); + }); + + group('toWallClockUtc / wallClockUtcToLocal helpers', () { + test( + 'toWallClockUtc preserves wall-clock components from local DateTime', + () { + final local = DateTime(2024, 6, 1, 10, 30, 45, 123); + final result = TripMediaScanner.toWallClockUtc(local); + expect(result.isUtc, isTrue); + expect(result.year, 2024); + expect(result.month, 6); + expect(result.day, 1); + expect(result.hour, 10); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + }, + ); + + test('toWallClockUtc returns input unchanged when already UTC', () { + final utc = DateTime.utc(2024, 6, 1, 10, 30, 45); + final result = TripMediaScanner.toWallClockUtc(utc); + expect(identical(result, utc), isTrue); + }); + + test('wallClockUtcToLocal preserves wall-clock components', () { + final utc = DateTime.utc(2024, 6, 1, 10, 30, 45, 123); + final result = TripMediaScanner.wallClockUtcToLocal(utc); + expect(result.isUtc, isFalse); + expect(result.year, 2024); + expect(result.month, 6); + expect(result.day, 1); + expect(result.hour, 10); + expect(result.minute, 30); + expect(result.second, 45); + expect(result.millisecond, 123); + }); + + test( + 'wallClockUtcToLocal returns input unchanged when already local', + () { + final local = DateTime(2024, 6, 1, 10, 30, 45); + final result = TripMediaScanner.wallClockUtcToLocal(local); + expect(identical(result, local), isTrue); + }, + ); + }); + + group('scanGalleryForDive', () { + test('returns null when permission is denied', () async { + final picker = _StubPhotoPicker( + permission: PhotoPermissionStatus.denied, + ); + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + ); + final result = await TripMediaScanner.scanGalleryForDive( + dive: dive, + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + expect(result, isNull); + }); + + test( + 'returns assets within the buffer window, filtering already-linked', + () async { + final assets = [ + _testAsset('a-new', createdAt: DateTime(2024, 1, 15, 10, 30)), + _testAsset('a-old', createdAt: DateTime(2024, 1, 15, 10, 45)), + ]; + final picker = _StubPhotoPicker(assets: assets); + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + entryLocation: const GeoPoint(11, 120), + ); + + final result = await TripMediaScanner.scanGalleryForDive( + dive: dive, + linked: [_linkedRow('m-old', platformAssetId: 'a-old')], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + + expect(result, hasLength(1)); + expect(result!.first.id, 'a-new'); + // The picker was called with local-time bounds (UTC bounds were + // adjusted by pre/post buffers and converted via wallClockUtcToLocal). + expect(picker.lastStart, isNotNull); + expect(picker.lastEnd, isNotNull); + expect(picker.lastStart!.isUtc, isFalse); + }, + ); + + test( + 'uses dateTime + duration fallback when entry/exit not set', + () async { + final picker = _StubPhotoPicker( + assets: [ + _testAsset('a1', createdAt: DateTime(2024, 1, 15, 10, 30)), + ], + ); + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + // no entryTime / exitTime / runtime + ); + final result = await TripMediaScanner.scanGalleryForDive( + dive: dive, + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + expect(result, hasLength(1)); + }, + ); + + test('filters a photo linked on another device, which the synced id ' + 'alone cannot recognise (#885)', () async { + final picker = _StubPhotoPicker( + assets: [ + _testAsset('mac-1', createdAt: DateTime(2024, 1, 15, 10, 30)), + _testAsset('mac-2', createdAt: DateTime(2024, 1, 15, 10, 45)), + ], + ); + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + ); + + final result = await TripMediaScanner.scanGalleryForDive( + dive: dive, + linked: [_linkedRow('m1', platformAssetId: 'iphone-1')], + linkedGalleryAssets: LinkedGalleryAssets( + resolve: (item) async => item.id == 'm1' + ? const ResolutionResult( + localAssetId: 'mac-1', + status: ResolutionStatus.resolved, + ) + : const ResolutionResult(status: ResolutionStatus.unavailable), + ), + photoPickerService: picker, + ); + + expect(result!.map((a) => a.id), ['mac-2']); + }); + }); + + group('scanGalleryForTrip', () { + test( + 'counts a burst linked on another device as already linked', + () async { + // Two frames in one second at one size, both linked on the iPhone: + // the resolver cannot say which row is which frame, but between + // them the rows account for both. + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + ); + MediaItem burstRow(String id) => MediaItem( + id: id, + diveId: 'dive-1', + platformAssetId: 'iphone-$id', + mediaType: MediaType.photo, + takenAt: DateTime.utc(2024, 1, 15, 10, 30, 7), + width: 1920, + height: 1080, + createdAt: DateTime.utc(2024, 1, 15), + updatedAt: DateTime.utc(2024, 1, 15), + ); + final picker = _StubPhotoPicker( + assets: [ + _testAsset('mac-1', createdAt: DateTime(2024, 1, 15, 10, 30, 7)), + _testAsset('mac-2', createdAt: DateTime(2024, 1, 15, 10, 30, 7)), + ], + ); + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: [dive], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 16), + linked: [burstRow('m1'), burstRow('m2')], + linkedGalleryAssets: LinkedGalleryAssets( + resolve: (_) async => + const ResolutionResult(status: ResolutionStatus.unavailable), + ), + photoPickerService: picker, + ); + + expect(result!.alreadyLinkedCount, 2); + expect(result.totalNewPhotos, 0); + }, + ); + + test('returns null when permission is denied', () async { + final picker = _StubPhotoPicker( + permission: PhotoPermissionStatus.denied, + ); + final result = await TripMediaScanner.scanGalleryForTrip( + dives: const [], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 17), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + expect(result, isNull); + }); + + test('groups matched assets by dive and surfaces unmatched', () async { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + ); + // a1: during the dive → matched + // a2: outside dive bounds → unmatched + // a3: already linked → filtered out before matching + final assets = [ + _testAsset( + 'a1', + createdAt: DateTime(2024, 1, 15, 10, 30), + latitude: 30.0, + longitude: -120.0, + ), + _testAsset('a2', createdAt: DateTime(2024, 1, 15, 18, 0)), + _testAsset('a3', createdAt: DateTime(2024, 1, 15, 10, 45)), + ]; + final picker = _StubPhotoPicker(assets: assets); + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: [dive], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 16), + linked: [_linkedRow('m3', platformAssetId: 'a3')], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + + expect(result, isNotNull); + expect(result!.alreadyLinkedCount, 1); + expect(result.matchedByDive[dive], hasLength(1)); + expect(result.matchedByDive[dive]!.first.id, 'a1'); + expect(result.unmatched, hasLength(1)); + expect(result.unmatched.first.id, 'a2'); + }); + + test( + 'queries through the full trip end day with timezone slack', + () async { + final picker = _StubPhotoPicker(); + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: const [], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 17), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + + expect(result, isNotNull); + expect(picker.lastStart, DateTime(2024, 1, 14)); + expect(picker.lastEnd, DateTime(2024, 1, 18, 23, 59, 59, 999, 999)); + }, + ); + + test('matches shifted Photos asset using EXIF fallback time', () async { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + ); + final picker = _StubPhotoPicker( + assets: [ + _testAsset('shifted', createdAt: DateTime(2024, 1, 14, 19, 49, 11)), + ], + ); + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: [dive], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 15), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + assetMetadataResolver: (asset) async { + expect(asset.id, 'shifted'); + return MediaSourceMetadata( + takenAt: DateTime.utc(2024, 1, 15, 10, 30), + mimeType: 'image/jpeg', + ); + }, + ); + + expect(result, isNotNull); + expect(result!.matchedByDive[dive], hasLength(1)); + expect(result.matchedByDive[dive]!.single.id, 'shifted'); + expect(result.unmatched, isEmpty); + }); + + test( + 'matches in-trip shifted Photos asset using EXIF fallback time', + () async { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + entryLocation: const GeoPoint(18, -60), + ); + final picker = _StubPhotoPicker( + assets: [ + _testAsset('shifted', createdAt: DateTime(2024, 1, 15, 7, 30)), + ], + ); + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: [dive], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 15), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + assetMetadataResolver: (asset) async { + expect(asset.id, 'shifted'); + return MediaSourceMetadata( + takenAt: DateTime.utc(2024, 1, 15, 10, 30), + mimeType: 'image/jpeg', + ); + }, + ); + + expect(result, isNotNull); + expect(result!.matchedByDive[dive], hasLength(1)); + expect(result.matchedByDive[dive]!.single.id, 'shifted'); + expect(result.unmatched, isEmpty); + }, + ); + + test( + 'matches shifted Photos asset with no loaded dive location', + () async { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + ); + final picker = _StubPhotoPicker( + assets: [ + _testAsset('shifted', createdAt: DateTime(2024, 1, 14, 19, 30)), + ], + ); + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: [dive], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 15), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + assetMetadataResolver: (asset) async { + expect(asset.id, 'shifted'); + return MediaSourceMetadata( + takenAt: DateTime.utc(2024, 1, 15, 10, 30), + mimeType: 'image/jpeg', + ); + }, + ); + + expect(result, isNotNull); + expect(result!.matchedByDive[dive], hasLength(1)); + expect(result.matchedByDive[dive]!.single.id, 'shifted'); + expect(result.unmatched, isEmpty); + }, + ); + + test( + 'does not load metadata for unrelated slack-window assets', + () async { + final dive = Dive( + id: 'dive-1', + dateTime: DateTime.utc(2024, 1, 15, 10, 0), + entryTime: DateTime.utc(2024, 1, 15, 10, 0), + exitTime: DateTime.utc(2024, 1, 15, 11, 0), + entryLocation: const GeoPoint(18, -60), + ); + final picker = _StubPhotoPicker( + assets: [ + _testAsset('unrelated', createdAt: DateTime(2024, 1, 14, 3, 30)), + _testAsset('candidate', createdAt: DateTime(2024, 1, 15, 7, 30)), + ], + ); + final metadataRequests = []; + + final result = await TripMediaScanner.scanGalleryForTrip( + dives: [dive], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 15), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + assetMetadataResolver: (asset) async { + metadataRequests.add(asset.id); + return null; + }, + ); + + expect(result, isNotNull); + expect(metadataRequests, ['candidate']); + }, + ); + + test('handles permission limited (still scans)', () async { + final picker = _StubPhotoPicker( + permission: PhotoPermissionStatus.limited, + ); + final result = await TripMediaScanner.scanGalleryForTrip( + dives: const [], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 17), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + expect(result, isNotNull); + }); + + test('returns empty unmatched when no assets', () async { + final picker = _StubPhotoPicker(); + final result = await TripMediaScanner.scanGalleryForTrip( + dives: const [], + tripStartDate: DateTime.utc(2024, 1, 15), + tripEndDate: DateTime.utc(2024, 1, 17), + linked: const [], + linkedGalleryAssets: const LinkedGalleryAssets(), + photoPickerService: picker, + ); + expect(result, isNotNull); + expect(result!.unmatched, isEmpty); + expect(result.matchedByDive, isEmpty); + }); + }); + }); +} diff --git a/test/features/media/presentation/pages/photo_picker_page_session_test.dart b/test/features/media/presentation/pages/photo_picker_page_session_test.dart index 819947366a..43c3ddf27b 100644 --- a/test/features/media/presentation/pages/photo_picker_page_session_test.dart +++ b/test/features/media/presentation/pages/photo_picker_page_session_test.dart @@ -25,6 +25,9 @@ import '../../../../helpers/mock_providers.dart'; /// Gallery service with library access and two photos in every range. class _GalleryService implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + @override bool get supportsGalleryBrowsing => true; diff --git a/test/features/media/presentation/pages/photo_picker_page_tab_shell_test.dart b/test/features/media/presentation/pages/photo_picker_page_tab_shell_test.dart index 0c373bee38..301891f687 100644 --- a/test/features/media/presentation/pages/photo_picker_page_tab_shell_test.dart +++ b/test/features/media/presentation/pages/photo_picker_page_tab_shell_test.dart @@ -34,6 +34,9 @@ import 'package:submersion/features/media/presentation/widgets/url_tab.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; class _StubPhotoPickerService implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + @override bool get supportsGalleryBrowsing => true; @override diff --git a/test/features/media/presentation/providers/photo_picker_providers_test.dart b/test/features/media/presentation/providers/photo_picker_providers_test.dart index 04393dedde..3aa4f06f64 100644 --- a/test/features/media/presentation/providers/photo_picker_providers_test.dart +++ b/test/features/media/presentation/providers/photo_picker_providers_test.dart @@ -10,6 +10,9 @@ import 'package:submersion/features/media/presentation/providers/photo_picker_pr /// Holds both permission calls open until [gate] completes, and fails them /// instead when [fail] is set. class _GatedPermissionService implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + final gate = Completer(); bool fail = false; diff --git a/test/features/trips/presentation/helpers/trip_scan_actions_test.dart b/test/features/trips/presentation/helpers/trip_scan_actions_test.dart index ba8fe95d18..4ff366eb88 100644 --- a/test/features/trips/presentation/helpers/trip_scan_actions_test.dart +++ b/test/features/trips/presentation/helpers/trip_scan_actions_test.dart @@ -23,6 +23,9 @@ import '../../../../helpers/mock_providers.dart'; /// Photo picker that always denies permission. class _DeniedPicker implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + @override Future requestPermission() async => PhotoPermissionStatus.denied; @@ -47,6 +50,9 @@ class _DeniedPicker implements PhotoPickerService { /// Photo picker with access granted over a fixed library. class _GrantedPicker implements PhotoPickerService { + @override + Future currentPermission() => checkPermission(); + _GrantedPicker(this.library); final List library; diff --git a/test/helpers/fake_photo_picker_service.dart b/test/helpers/fake_photo_picker_service.dart index aed6abf22f..0362b04572 100644 --- a/test/helpers/fake_photo_picker_service.dart +++ b/test/helpers/fake_photo_picker_service.dart @@ -61,6 +61,13 @@ class FakePhotoPickerService implements PhotoPickerService, GalleryAssetReader { @override final bool supportsGalleryBrowsing; + /// How many times the app asked for access, which shows the OS prompt on + /// a real device. Background resolution must leave it at zero. + int prompts = 0; + + /// When set, gallery queries throw it (a platform channel failure). + Object? queryError; + void add(FakeGalleryAsset asset) => _assets[asset.id] = asset; void remove(String id) => _assets.remove(id); @@ -86,6 +93,8 @@ class FakePhotoPickerService implements PhotoPickerService, GalleryAssetReader { DateTime start, DateTime end, ) async { + final error = queryError; + if (error != null) throw error; final matches = [ for (final a in _assets.values) if (_visible(a.id) != null && @@ -105,10 +114,19 @@ class FakePhotoPickerService implements PhotoPickerService, GalleryAssetReader { _visible(assetId)?.bytes; @override - Future checkPermission() async => permission; + Future checkPermission() async { + prompts++; + return permission; + } + + @override + Future currentPermission() async => permission; @override - Future requestPermission() async => permission; + Future requestPermission() async { + prompts++; + return permission; + } @override Future getFilePath(String assetId) async => null; From 8006b43323ef5ae6d4f679542ced8a326ae347ca Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:35:21 -0400 Subject: [PATCH 03/12] fix(media): limited photo access is inconclusive, not missing Resolution admitted a limited selection as full access, so a photo the user had not selected went unfound, was cached unresolved, and on the device that linked it read as notFound, which orphans the row everywhere. Under limited access a miss is now accessDenied, flagged limitedAccess through to UnavailableData, and caches nothing. Turns S7 green. --- .../resolvers/platform_gallery_resolver.dart | 14 +++-- .../services/asset_resolution_service.dart | 22 +++++++- .../value_objects/media_source_data.dart | 7 +++ .../platform_gallery_resolver_test.dart | 32 +++++++++++ .../asset_resolution_permission_test.dart | 54 +++++++++++++++++++ .../two_device/resolution_scenarios_test.dart | 46 ++++++++-------- 6 files changed, 145 insertions(+), 30 deletions(-) diff --git a/lib/features/media/data/resolvers/platform_gallery_resolver.dart b/lib/features/media/data/resolvers/platform_gallery_resolver.dart index 8713e57bfc..5ed144df6e 100644 --- a/lib/features/media/data/resolvers/platform_gallery_resolver.dart +++ b/lib/features/media/data/resolvers/platform_gallery_resolver.dart @@ -154,7 +154,10 @@ 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, + ); } final resolvedId = resolution.localAssetId; if (resolvedId == null) return _missing(item); @@ -191,9 +194,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); } diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index f93acb3fdb..12541e078d 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -33,7 +33,16 @@ class ResolutionResult { final String? localAssetId; final ResolutionStatus status; - const ResolutionResult({this.localAssetId, required this.status}); + /// With [ResolutionStatus.accessDenied] only: the gallery was searched + /// through a limited selection, so a miss may be a photo the user did not + /// select rather than one that is gone. + final bool limitedAccess; + + const ResolutionResult({ + this.localAssetId, + required this.status, + this.limitedAccess = false, + }); } /// Service for resolving cross-device photo asset IDs. @@ -247,7 +256,17 @@ class AssetResolutionService { } final candidates = byId.values.toList(); + // A limited selection hides photos the device does have (spec 6.3): a + // miss under it is not evidence of absence, and caching it would back + // off a photo the user can make visible in a moment. + final limited = permission == PhotoPermissionStatus.limited; + const limitedMiss = ResolutionResult( + status: ResolutionStatus.accessDenied, + limitedAccess: true, + ); + if (candidates.isEmpty) { + if (limited) return limitedMiss; await _cacheUnresolved(item.id); return const ResolutionResult(status: ResolutionStatus.unavailable); } @@ -310,6 +329,7 @@ class AssetResolutionService { } // Tier 4: unresolved + if (limited) return limitedMiss; await _cacheUnresolved(item.id); _log.info('Could not resolve media ${item.id} -- marked unresolved'); return const ResolutionResult(status: ResolutionStatus.unavailable); diff --git a/lib/features/media/domain/value_objects/media_source_data.dart b/lib/features/media/domain/value_objects/media_source_data.dart index 5a6c8d9939..29e84ae4d4 100644 --- a/lib/features/media/domain/value_objects/media_source_data.dart +++ b/lib/features/media/domain/value_objects/media_source_data.dart @@ -178,9 +178,16 @@ class UnavailableData extends MediaSourceData { final String? userMessage; final String? originDeviceLabel; + /// With [UnavailableKind.accessDenied] only: the photo library was + /// searched through a limited selection, so the photo may be in the + /// library but outside what the user allowed (media sync program spec + /// 6.3). The viewer offers full access or the selection sheet for it. + final bool limitedAccess; + const UnavailableData({ required this.kind, this.userMessage, this.originDeviceLabel, + this.limitedAccess = false, }); } diff --git a/test/features/media/data/resolvers/platform_gallery_resolver_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_test.dart index 00fa5c428f..ce88232f32 100644 --- a/test/features/media/data/resolvers/platform_gallery_resolver_test.dart +++ b/test/features/media/data/resolvers/platform_gallery_resolver_test.dart @@ -234,6 +234,38 @@ void main() { expect((data as UnavailableData).kind, UnavailableKind.accessDenied); }); + // A limited selection hides photos the library does have; the flag is + // what the viewer's "Allow full access" actions key on (spec 6.3). + test('a limited search reports accessDenied, flagged limited', () async { + final r = PlatformGalleryResolver( + resolutionService: _FakeAssetResolutionService( + const ResolutionResult( + status: ResolutionStatus.accessDenied, + limitedAccess: true, + ), + ), + ); + + final full = await r.resolve(_gallery(assetId: 'A')); + final thumb = await r.resolveThumbnail( + _gallery(assetId: 'A'), + target: const Size(200, 200), + ); + + for (final data in [full, thumb]) { + expect((data as UnavailableData).kind, UnavailableKind.accessDenied); + expect(data.limitedAccess, isTrue); + } + }); + + test('plain denied access is not flagged limited', () async { + final r = PlatformGalleryResolver( + resolutionService: _accessDeniedService(), + ); + final data = await r.resolve(_gallery(assetId: 'A')); + expect((data as UnavailableData).limitedAccess, isFalse); + }); + test('verify reports accessDenied', () async { final r = PlatformGalleryResolver( resolutionService: _accessDeniedService(), diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart index 009e8baad1..c6fb31ea40 100644 --- a/test/features/media/data/services/asset_resolution_permission_test.dart +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -78,6 +78,60 @@ void main() { }, ); + // A limited selection hides photos the device does have: a miss is not + // evidence of absence, and caching it would back off a photo the user can + // make visible in a moment. + test( + 'under limited access a photo outside the selection is inconclusive', + () async { + addPhoto(); + library + ..permission = PhotoPermissionStatus.limited + ..hiddenFromLimitedAccess.add('B-1'); + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isTrue); + expect(await cache.getCacheEntry('m1'), isNull); + }, + ); + + test( + 'under limited access a photo in the selection still resolves', + () async { + addPhoto(); + library.permission = PhotoPermissionStatus.limited; + + final r = await service.resolveAssetId(row()); + + expect(r.localAssetId, 'B-1'); + }, + ); + + // Candidates in the window, none of them this photo: still a limited + // view, so still inconclusive. + test('under limited access a miss past the tiers is inconclusive', () async { + library + ..add( + FakeGalleryAsset( + id: 'B-other', + bytes: Uint8List.fromList([2]), + takenAt: taken, + width: 10, + height: 10, + filename: 'OTHER.JPG', + ), + ) + ..permission = PhotoPermissionStatus.limited; + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isTrue); + expect(await cache.getCacheEntry('m1'), isNull); + }); + test('a permission read that fails is inconclusive', () async { final failing = _FailingPermission(); final r = await AssetResolutionService( diff --git a/test/features/media/two_device/resolution_scenarios_test.dart b/test/features/media/two_device/resolution_scenarios_test.dart index 4d676a74a5..79f3ad07d4 100644 --- a/test/features/media/two_device/resolution_scenarios_test.dart +++ b/test/features/media/two_device/resolution_scenarios_test.dart @@ -121,29 +121,25 @@ void main() { // Slice 8 gives FakeGalleryAsset a cloudId and stamps it at link time. }, skip: 'Media sync program S6: turns green in slice 8 (cloud identifier)'); - test( - 'S7: limited photo access on the origin device is inconclusive, ' - 'not missing', - () async { - final dive = await h.b.createDive(); - final id = await h.b.linkGalleryPhoto( - FakeGalleryAsset(id: 'B-7', bytes: photo, takenAt: taken), - diveId: dive, - ); - expect(await h.b.tileOutcome(id), TileOutcome.native); - - // The user later grants limited access and this photo is outside the - // selected subset. - h.b.gallery.permission = PhotoPermissionStatus.limited; - h.b.gallery.hiddenFromLimitedAccess.add('B-7'); - await h.b.assetCache.clearEntry(id); - - final tile = await h.b.tile(id); - expect((tile.data as UnavailableData).kind, UnavailableKind.accessDenied); - await h.b.checkTile(id); - expect((await h.b.media(id))!.isOrphaned, isFalse); - }, - skip: - 'Media sync program S7: turns green in slice 9 (Android limited access)', - ); + test('S7: limited photo access on the origin device is inconclusive, ' + 'not missing', () async { + final dive = await h.b.createDive(); + final id = await h.b.linkGalleryPhoto( + FakeGalleryAsset(id: 'B-7', bytes: photo, takenAt: taken), + diveId: dive, + ); + expect(await h.b.tileOutcome(id), TileOutcome.native); + + // The user later grants limited access and this photo is outside the + // selected subset. + h.b.gallery.permission = PhotoPermissionStatus.limited; + h.b.gallery.hiddenFromLimitedAccess.add('B-7'); + await h.b.assetCache.clearEntry(id); + + final tile = await h.b.tile(id); + expect((tile.data as UnavailableData).kind, UnavailableKind.accessDenied); + expect((tile.data as UnavailableData).limitedAccess, isTrue); + await h.b.checkTile(id); + expect((await h.b.media(id))!.isOrphaned, isFalse); + }); } From 5457927f080f8d2dd4e777d77a355b3b921672d1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:38:48 -0400 Subject: [PATCH 04/12] fix(media): a lost Android read grant searches the library before anything is missing On the device that linked a file, a content URI that stopped reading was notFound whatever the reason, which orphans the row. The native handler already reports a lost grant as PERMISSION_DENIED; the resolver now searches the photo library by the metadata tiers first (a new AssetResolutionService.findInLibrary that needs no stored asset id), and a lost grant the search cannot recover is accessDenied, which verify reports as such. The branch is now injectable, so it runs in the test shards. --- .../data/resolvers/local_file_resolver.dart | 67 +++++++++-- .../services/asset_resolution_service.dart | 39 +++++++ .../providers/media_resolver_providers.dart | 14 +++ .../local_file_resolver_content_uri_test.dart | 106 ++++++++++++++++++ .../asset_resolution_permission_test.dart | 37 ++++++ 5 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart diff --git a/lib/features/media/data/resolvers/local_file_resolver.dart b/lib/features/media/data/resolvers/local_file_resolver.dart index 0e7c09ac86..4895e29be9 100644 --- a/lib/features/media/data/resolvers/local_file_resolver.dart +++ b/lib/features/media/data/resolvers/local_file_resolver.dart @@ -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'; @@ -68,12 +69,16 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe { bool Function()? usesSecurityScopedBookmarks, Future Function()? localDeviceId, Future Function(String deviceId)? deviceLabel, + bool Function()? readsContentUris, + Future 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, @@ -122,6 +127,16 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe { /// placeholder. Consulted only on a foreign-origin miss. final Future 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 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; @@ -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()) { @@ -363,6 +379,33 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe { /// assuming online falls through to the file itself, which is what this /// resolver did before the probe was hoisted ahead of it, and lets the /// existing exists() / open() path produce the real answer. + /// 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 _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, + ); + } + Future _volumeOnlineOrAssumed(String path) async { try { return await _volumeOnline(path); @@ -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 diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index 12541e078d..bc539da072 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -186,6 +186,45 @@ class AssetResolutionService { ); } + return _searchGallery(item); + } + + /// Finds [item] in the photo library by the metadata tiers alone, for a + /// row whose stored pointer no longer reads: a file whose Android read + /// grant was lost, which is usually still in the library (media sync + /// program spec 6.3). Needs no stored asset id. Honors the same cache and + /// backoff as [resolveAssetId], and shares its in-flight searches. + Future findInLibrary(MediaItem item) async { + if (!_photoPickerService.supportsGalleryBrowsing) { + return const ResolutionResult(status: ResolutionStatus.unavailable); + } + final cachedId = await _cacheRepository.getCachedAssetId(item.id); + if (cachedId != null) { + return ResolutionResult( + localAssetId: cachedId, + status: ResolutionStatus.resolved, + ); + } + final cacheEntry = await _cacheRepository.getCacheEntry(item.id); + if (cacheEntry != null && + cacheEntry.localAssetId == null && + !await _cacheRepository.isExpired(item.id)) { + return const ResolutionResult(status: ResolutionStatus.unavailable); + } + final pending = _pendingResolutions[item.id]; + if (pending != null) return pending; + final future = _searchGallery(item); + _pendingResolutions[item.id] = future; + try { + return await future; + } finally { + _pendingResolutions.remove(item.id); + } + } + + /// The permission gate and the metadata tiers: everything a search does + /// once the stored id has failed or there is none. + Future _searchGallery(MediaItem item) async { // A gallery query against a library the app cannot access yet returns // zero candidates -- indistinguishable from "genuinely no matching // photo" unless permission is checked directly. Skip the query (and, diff --git a/lib/features/media/presentation/providers/media_resolver_providers.dart b/lib/features/media/presentation/providers/media_resolver_providers.dart index c89c79cf1c..8e87fb4683 100644 --- a/lib/features/media/presentation/providers/media_resolver_providers.dart +++ b/lib/features/media/presentation/providers/media_resolver_providers.dart @@ -28,6 +28,7 @@ import 'package:submersion/features/media/data/services/subscription_poller.dart import 'package:submersion/features/media/data/services/subscription_poller_scheduler.dart'; import 'package:submersion/features/media/data/services/video_thumbnail_service.dart'; import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; import 'package:submersion/features/media/data/resolvers/media_store_source_resolver.dart'; import 'package:submersion/features/media/data/services/gallery_asset_reader.dart'; import 'package:submersion/features/media/presentation/providers/lightroom_providers.dart'; @@ -124,6 +125,19 @@ final localFileResolverProvider = Provider((ref) { localDeviceId: () => SyncRepository().getDeviceId(), deviceLabel: (id) async => ref.read(peerDeviceNameStoreProvider).nameFor(id), + // A content URI that stopped reading on Android is searched for in the + // photo library by metadata before anything is decided (spec 6.3). + findInLibrary: (item) async { + final found = await ref + .read(assetResolutionServiceProvider) + .findInLibrary(item); + final id = found.localAssetId; + if (id == null) return null; + final bytes = await const PhotoManagerAssetReader().originBytes(id); + return bytes == null + ? null + : BytesData(bytes: bytes, servedFrom: ServedFrom.platformGallery); + }, ); // The resolver's fetch gate holds timers that outlive the fetch they bound, // so a rebuild or a container teardown with a tile still resolving would diff --git a/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart new file mode 100644 index 0000000000..444cfca642 --- /dev/null +++ b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/resolvers/local_file_resolver.dart'; +import 'package:submersion/features/media/data/services/exif_extractor.dart'; +import 'package:submersion/features/media/data/services/local_bookmark_storage.dart'; +import 'package:submersion/features/media/data/services/local_media_platform.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; +import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; + +class _NullBookmarkStorage extends LocalBookmarkStorage { + _NullBookmarkStorage() : super(storage: null as dynamic); +} + +/// Android's content-URI read, failing the way the native handler reports: +/// `PERMISSION_DENIED` for a SecurityException (the read grant is gone), +/// `READ_FAILED` for anything else. +class _FailingUriPlatform extends LocalMediaPlatform { + _FailingUriPlatform(this.code); + + final String code; + + @override + Future readUriBytes(String uri) async => + throw PlatformException(code: code, message: 'denied'); +} + +/// On Android a file link is a content URI. When it stops reading on the +/// device that linked it, the photo library is searched by metadata before +/// anything is decided, and a lost grant is never proof the file is gone +/// (media sync program spec 6.3). +void main() { + final recovered = BytesData(bytes: Uint8List.fromList([7])); + + MediaItem row({String origin = 'me'}) => MediaItem( + id: 'f1', + mediaType: MediaType.photo, + sourceType: MediaSourceType.localFile, + bookmarkRef: 'content://media/external/images/media/42', + originDeviceId: origin, + takenAt: DateTime.utc(2026, 7, 1), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ); + + var searches = 0; + setUp(() => searches = 0); + + LocalFileResolver resolver(String code, {MediaSourceData? found}) => + LocalFileResolver( + bookmarkStorage: _NullBookmarkStorage(), + platform: _FailingUriPlatform(code), + exifExtractor: ExifExtractor(), + readsContentUris: () => true, + localDeviceId: () async => 'me', + findInLibrary: (item) async { + searches++; + return found; + }, + ); + + test('a lost grant the library search recovers serves the photo', () async { + final data = await resolver( + 'PERMISSION_DENIED', + found: recovered, + ).resolve(row()); + + expect(data, recovered); + }); + + test('a lost grant the search cannot recover is inconclusive', () async { + final data = await resolver('PERMISSION_DENIED').resolve(row()); + + expect((data as UnavailableData).kind, UnavailableKind.accessDenied); + expect(searches, 1); + }); + + test('a failed read the search cannot recover is notFound', () async { + final data = await resolver('READ_FAILED').resolve(row()); + + expect((data as UnavailableData).kind, UnavailableKind.notFound); + expect(searches, 1); + }); + + // Another device's content URI never had a grant here, so it is not a + // lost grant and not worth a library search per render. + test('another device\'s content URI is not searched', () async { + final data = await resolver( + 'PERMISSION_DENIED', + found: recovered, + ).resolve(row(origin: 'peer')); + + expect((data as UnavailableData).kind, UnavailableKind.fromOtherDevice); + expect(searches, 0); + }); + + // The verification sweep would otherwise read an inconclusive answer as + // notFound and orphan the row. + test('verify reports a lost grant as accessDenied', () async { + expect( + await resolver('PERMISSION_DENIED').verify(row()), + VerifyResult.accessDenied, + ); + }); +} diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart index c6fb31ea40..3a4a2db7cf 100644 --- a/test/features/media/data/services/asset_resolution_permission_test.dart +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -132,6 +132,43 @@ void main() { expect(await cache.getCacheEntry('m1'), isNull); }); + /// A file linked on this device, not a gallery asset: no stored asset id, + /// only the metadata the tiers match on. + MediaItem fileRow() => MediaItem( + id: 'f1', + originalFilename: 'IMG_0001.JPG', + mediaType: MediaType.photo, + sourceType: MediaSourceType.localFile, + bookmarkRef: 'content://media/external/images/media/42', + width: 4032, + height: 3024, + takenAt: DateTime.utc(2026, 7, 1, 10, 30), + createdAt: DateTime.utc(2026, 7, 1), + updatedAt: DateTime.utc(2026, 7, 1), + ); + + // A file whose read grant was lost is usually still in the photo library + // (spec 6.3): the metadata tiers find it without a stored asset id. + test('findInLibrary matches a row with no asset id by metadata', () async { + addPhoto(); + + final r = await service.findInLibrary(fileRow()); + + expect(r.localAssetId, 'B-1'); + }); + + test('findInLibrary under limited access is inconclusive', () async { + addPhoto(); + library + ..permission = PhotoPermissionStatus.limited + ..hiddenFromLimitedAccess.add('B-1'); + + final r = await service.findInLibrary(fileRow()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isTrue); + }); + test('a permission read that fails is inconclusive', () async { final failing = _FailingPermission(); final r = await AssetResolutionService( From ecc0188c6f3adb165512a6ca2a30c050ad7afded Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:43:19 -0400 Subject: [PATCH 05/12] feat(media): offer full access and the photo selection where a photo is out of reach A photo outside the user's limited selection now reads 'Not in your allowed photos' on its tile. The full-screen viewer and the info panel offer 'Allow full access', which opens the system settings, and 'Choose photo again', which opens the system's limited-selection sheet, and look for the photo again when the user returns. Grid tiles show the placeholder alone. Strings in all 11 locales. --- .../data/services/photo_access_actions.dart | 25 ++++++ .../presentation/pages/media_viewer_page.dart | 6 +- .../providers/photo_access_providers.dart | 27 ++++++ .../widgets/limited_access_actions.dart | 58 +++++++++++++ .../widgets/media_info_panel.dart | 13 +++ .../presentation/widgets/media_item_view.dart | 22 +++++ .../unavailable_media_placeholder.dart | 10 ++- lib/l10n/arb/app_ar.arb | 3 + lib/l10n/arb/app_de.arb | 3 + lib/l10n/arb/app_en.arb | 3 + lib/l10n/arb/app_es.arb | 3 + lib/l10n/arb/app_fr.arb | 3 + lib/l10n/arb/app_he.arb | 3 + lib/l10n/arb/app_hu.arb | 3 + lib/l10n/arb/app_it.arb | 3 + lib/l10n/arb/app_localizations.dart | 18 ++++ lib/l10n/arb/app_localizations_ar.dart | 10 +++ lib/l10n/arb/app_localizations_de.dart | 10 +++ lib/l10n/arb/app_localizations_en.dart | 10 +++ lib/l10n/arb/app_localizations_es.dart | 10 +++ lib/l10n/arb/app_localizations_fr.dart | 12 +++ lib/l10n/arb/app_localizations_he.dart | 10 +++ lib/l10n/arb/app_localizations_hu.dart | 11 +++ lib/l10n/arb/app_localizations_it.dart | 10 +++ lib/l10n/arb/app_localizations_nl.dart | 11 +++ lib/l10n/arb/app_localizations_pt.dart | 11 +++ lib/l10n/arb/app_localizations_zh.dart | 9 ++ lib/l10n/arb/app_nl.arb | 3 + lib/l10n/arb/app_pt.arb | 3 + lib/l10n/arb/app_zh.arb | 3 + .../widgets/limited_access_actions_test.dart | 87 +++++++++++++++++++ .../widgets/media_info_panel_test.dart | 44 ++++++++++ .../widgets/media_item_view_test.dart | 54 ++++++++++++ .../unavailable_media_placeholder_test.dart | 28 ++++++ 34 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 lib/features/media/data/services/photo_access_actions.dart create mode 100644 lib/features/media/presentation/providers/photo_access_providers.dart create mode 100644 lib/features/media/presentation/widgets/limited_access_actions.dart create mode 100644 test/features/media/presentation/widgets/limited_access_actions_test.dart diff --git a/lib/features/media/data/services/photo_access_actions.dart b/lib/features/media/data/services/photo_access_actions.dart new file mode 100644 index 0000000000..b9a9ee2d54 --- /dev/null +++ b/lib/features/media/data/services/photo_access_actions.dart @@ -0,0 +1,25 @@ +import 'package:photo_manager/photo_manager.dart'; + +/// The two ways back to a photo outside the user's limited selection +/// (media sync program spec 6.3). Both hand off to the system, and both +/// return once the user comes back; the caller re-resolves then. +abstract interface class PhotoAccessActions { + /// Opens this app's page in the system settings, where full photo access + /// is granted. + Future openSettings(); + + /// Opens the system's limited-selection sheet, where the user adds photos + /// to what the app may see (iOS 14 and later, Android 14 and later). + Future chooseMorePhotos(); +} + +/// [PhotoAccessActions] through photo_manager. +class PhotoManagerAccessActions implements PhotoAccessActions { + const PhotoManagerAccessActions(); + + @override + Future openSettings() => PhotoManager.openSetting(); + + @override + Future chooseMorePhotos() => PhotoManager.presentLimited(); +} diff --git a/lib/features/media/presentation/pages/media_viewer_page.dart b/lib/features/media/presentation/pages/media_viewer_page.dart index fcb0337c38..93973354c0 100644 --- a/lib/features/media/presentation/pages/media_viewer_page.dart +++ b/lib/features/media/presentation/pages/media_viewer_page.dart @@ -874,7 +874,11 @@ class _PhotoItem extends StatelessWidget { @override Widget build(BuildContext context) { - return MediaItemView(item: item, fit: BoxFit.contain); + return MediaItemView( + item: item, + fit: BoxFit.contain, + showAccessActions: true, + ); } } diff --git a/lib/features/media/presentation/providers/photo_access_providers.dart b/lib/features/media/presentation/providers/photo_access_providers.dart new file mode 100644 index 0000000000..518c473144 --- /dev/null +++ b/lib/features/media/presentation/providers/photo_access_providers.dart @@ -0,0 +1,27 @@ +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/data/services/photo_access_actions.dart'; +import 'package:submersion/features/media/data/services/photo_picker_service.dart'; +import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; + +/// The system hand-offs for a photo outside a limited selection. +final photoAccessActionsProvider = Provider( + (ref) => const PhotoManagerAccessActions(), +); + +/// Whether this device's photo access is a limited selection, read without +/// prompting (media sync program spec 6.3). False where there is no photo +/// library, and when the platform cannot say: the actions it gates are an +/// offer, never a verdict. +// no-tick: reads the platform's permission state, not a table. Callers +// invalidate it after sending the user to change that state. +final galleryAccessLimitedProvider = FutureProvider.autoDispose(( + ref, +) async { + final photos = ref.watch(photoPickerServiceProvider); + if (!photos.supportsGalleryBrowsing) return false; + try { + return await photos.currentPermission() == PhotoPermissionStatus.limited; + } on Object { + return false; + } +}); diff --git a/lib/features/media/presentation/widgets/limited_access_actions.dart b/lib/features/media/presentation/widgets/limited_access_actions.dart new file mode 100644 index 0000000000..6eacd15719 --- /dev/null +++ b/lib/features/media/presentation/widgets/limited_access_actions.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +import 'package:submersion/core/models/log_entry.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/core/services/logger_service.dart'; +import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/l10n/l10n_extension.dart'; + +/// "Allow full access" and "Choose photo again", for a photo outside the +/// user's limited selection (media sync program spec 6.3). Each hands off +/// to the system and calls [onChanged] when the user comes back, so the +/// caller can look for the photo again. +class LimitedAccessActions extends ConsumerWidget { + const LimitedAccessActions({super.key, required this.onChanged}); + + /// Called after either action returns, whether or not it succeeded: + /// the user may have changed access in the meantime. + final VoidCallback onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final actions = ref.watch(photoAccessActionsProvider); + + Future run(Future Function() action) async { + try { + await action(); + } on Object catch (e, stackTrace) { + // An OS without the limited-selection sheet, or a platform channel + // failure. The buttons are an offer; a failure is logged, not shown. + LoggerService.forClass( + LimitedAccessActions, + category: LogCategory.media, + ).warning( + 'Photo access action failed', + error: e, + stackTrace: stackTrace, + ); + } + if (context.mounted) onChanged(); + } + + return Wrap( + alignment: WrapAlignment.center, + spacing: 8, + children: [ + TextButton( + onPressed: () => run(actions.openSettings), + child: Text(l10n.media_limitedAccess_allowFullAccess), + ), + TextButton( + onPressed: () => run(actions.chooseMorePhotos), + child: Text(l10n.media_limitedAccess_choosePhotoAgain), + ), + ], + ); + } +} diff --git a/lib/features/media/presentation/widgets/media_info_panel.dart b/lib/features/media/presentation/widgets/media_info_panel.dart index b6629829bb..08498863e5 100644 --- a/lib/features/media/presentation/widgets/media_info_panel.dart +++ b/lib/features/media/presentation/widgets/media_info_panel.dart @@ -21,6 +21,8 @@ import 'package:submersion/features/media/presentation/providers/media_health_pr import 'package:submersion/features/media/presentation/providers/media_provenance_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_serving_providers.dart'; +import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/limited_access_actions.dart'; import 'package:submersion/features/media/presentation/widgets/set_media_time_dialog.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; @@ -260,6 +262,17 @@ class _OriginSection extends ConsumerWidget { title: l10n.media_info_originSection, actions: [ _CheckNowButton(item: item), + // Under limited photo access a gallery photo may be outside what the + // user allowed (spec 6.3). Offered whenever access is limited: the + // panel reads stored facts, not this device's live verdict. + if (origin.sourceType == MediaSourceType.platformGallery && + ref.watch(galleryAccessLimitedProvider).value == true) + LimitedAccessActions( + onChanged: () { + ref.invalidate(galleryAccessLimitedProvider); + ref.invalidate(mediaByIdProvider(item.id)); + }, + ), // The repair engine's file candidate only makes sense for a row that // points at a path, so this is not offered for a missing gallery // asset, where picking a file would relink it to the wrong source diff --git a/lib/features/media/presentation/widgets/media_item_view.dart b/lib/features/media/presentation/widgets/media_item_view.dart index a91be649a6..3246bd5ba5 100644 --- a/lib/features/media/presentation/widgets/media_item_view.dart +++ b/lib/features/media/presentation/widgets/media_item_view.dart @@ -12,6 +12,7 @@ import 'package:submersion/features/media/domain/value_objects/media_source_data import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_resolver_providers.dart'; import 'package:submersion/features/media/presentation/providers/media_serving_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/limited_access_actions.dart'; import 'package:submersion/features/media/presentation/widgets/unavailable_media_placeholder.dart'; import 'package:submersion/features/media_store/presentation/providers/media_store_providers.dart'; @@ -62,12 +63,18 @@ class MediaItemView extends ConsumerStatefulWidget { /// the full-resolution original, with or without a [targetSize]. final bool thumbnail; + /// Whether a photo outside the user's limited selection offers "Allow + /// full access" and "Choose photo again" (spec 6.3). The full-screen + /// viewer sets it; a grid tile has no room for buttons. + final bool showAccessActions; + const MediaItemView({ super.key, required this.item, this.fit = BoxFit.cover, this.targetSize, this.thumbnail = false, + this.showAccessActions = false, }); @override @@ -424,6 +431,21 @@ class _MediaItemViewState extends ConsumerState { behavior: HitTestBehavior.opaque, child: UnavailableMediaPlaceholder(data: data), ), + // The viewer offers the ways back to a photo outside a limited + // selection (spec 6.3); a grid tile has no room for buttons and + // shows the placeholder alone. Only the viewer sets the flag, and + // it lays this out in bounded height. + UnavailableData( + kind: UnavailableKind.accessDenied, + limitedAccess: true, + ) + when widget.showAccessActions => + Column( + children: [ + Expanded(child: UnavailableMediaPlaceholder(data: data)), + LimitedAccessActions(onChanged: _retry), + ], + ), UnavailableData() => UnavailableMediaPlaceholder(data: data), }; }, diff --git a/lib/features/media/presentation/widgets/unavailable_media_placeholder.dart b/lib/features/media/presentation/widgets/unavailable_media_placeholder.dart index 3c1144edc3..6f3ed6994a 100644 --- a/lib/features/media/presentation/widgets/unavailable_media_placeholder.dart +++ b/lib/features/media/presentation/widgets/unavailable_media_placeholder.dart @@ -30,7 +30,7 @@ class UnavailableMediaPlaceholder extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ - Icon(_iconFor(data.kind), size: iconSize, color: scheme.outline), + Icon(_iconFor(data), size: iconSize, color: scheme.outline), const SizedBox(height: 4), Text( _messageFor(context, data), @@ -45,7 +45,7 @@ class UnavailableMediaPlaceholder extends StatelessWidget { ); } - IconData _iconFor(UnavailableKind kind) => switch (kind) { + IconData _iconFor(UnavailableData d) => switch (d.kind) { UnavailableKind.notFound => Icons.broken_image_outlined, UnavailableKind.unauthenticated => Icons.lock_outline, UnavailableKind.signInRequired => Icons.lock_outline, @@ -53,6 +53,10 @@ class UnavailableMediaPlaceholder extends StatelessWidget { UnavailableKind.networkError => Icons.cloud_off_outlined, UnavailableKind.volumeOffline => Icons.usb_off_outlined, UnavailableKind.stillFetching => Icons.hourglass_empty, + // A limited selection: the photo may be there, outside what the user + // allowed (spec 6.3), which is not the same as no access at all. + UnavailableKind.accessDenied when d.limitedAccess => + Icons.photo_library_outlined, UnavailableKind.accessDenied => Icons.no_photography_outlined, }; @@ -78,6 +82,8 @@ class UnavailableMediaPlaceholder extends StatelessWidget { l10n.media_unavailablePlaceholder_volumeOffline, UnavailableKind.stillFetching => l10n.media_unavailablePlaceholder_stillFetching, + UnavailableKind.accessDenied when d.limitedAccess => + l10n.media_unavailablePlaceholder_limitedAccess, UnavailableKind.accessDenied => l10n.media_unavailablePlaceholder_accessDenied, }; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index f18665ab74..2071828023 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "وحدة التخزين غير مثبتة", "media_unavailablePlaceholder_stillFetching": "ما زال قيد التحميل. اضغط لإعادة المحاولة.", "media_unavailablePlaceholder_accessDenied": "لا يوجد وصول إلى مكتبة الصور", + "media_unavailablePlaceholder_limitedAccess": "ليست ضمن الصور المسموح بها", + "media_limitedAccess_allowFullAccess": "السماح بالوصول الكامل", + "media_limitedAccess_choosePhotoAgain": "اختيار الصورة مجددًا", "attrLabel_hose_length_m": "طول الخرطوم", "attrLabel_hose_type": "نوع الخرطوم", "attrLabel_plate_material": "مادة اللوحة", diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 6d564b1d2b..ef79f2cee5 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -8293,6 +8293,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volume nicht eingebunden", "media_unavailablePlaceholder_stillFetching": "Wird noch geladen. Zum Wiederholen tippen.", "media_unavailablePlaceholder_accessDenied": "Kein Zugriff auf die Fotomediathek", + "media_unavailablePlaceholder_limitedAccess": "Nicht unter den freigegebenen Fotos", + "media_limitedAccess_allowFullAccess": "Vollzugriff erlauben", + "media_limitedAccess_choosePhotoAgain": "Foto erneut auswählen", "attrLabel_hose_length_m": "Schlauchlänge", "attrLabel_hose_type": "Schlauchtyp", "attrLabel_plate_material": "Plattenmaterial", diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 65c1282876..88d3a1d62a 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -18441,6 +18441,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volume not mounted", "media_unavailablePlaceholder_stillFetching": "Still loading. Tap to retry.", "media_unavailablePlaceholder_accessDenied": "No photo library access", + "media_unavailablePlaceholder_limitedAccess": "Not in your allowed photos", + "media_limitedAccess_allowFullAccess": "Allow full access", + "media_limitedAccess_choosePhotoAgain": "Choose photo again", "attrLabel_hose_length_m": "Hose length", "attrLabel_hose_type": "Hose type", "attrLabel_plate_material": "Plate material", diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 9e45bc940c..d47c8e9c73 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volumen no montado", "media_unavailablePlaceholder_stillFetching": "Aún se está cargando. Toca para reintentar.", "media_unavailablePlaceholder_accessDenied": "Sin acceso a la fototeca", + "media_unavailablePlaceholder_limitedAccess": "No está entre las fotos permitidas", + "media_limitedAccess_allowFullAccess": "Permitir acceso completo", + "media_limitedAccess_choosePhotoAgain": "Volver a elegir la foto", "attrLabel_hose_length_m": "Longitud del latiguillo", "attrLabel_hose_type": "Tipo de latiguillo", "attrLabel_plate_material": "Material de la placa", diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index b5a95c96e1..ffa7bc29bd 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volume non monté", "media_unavailablePlaceholder_stillFetching": "Chargement en cours. Touchez pour réessayer.", "media_unavailablePlaceholder_accessDenied": "Aucun accès à la photothèque", + "media_unavailablePlaceholder_limitedAccess": "Hors des photos autorisées", + "media_limitedAccess_allowFullAccess": "Autoriser l'accès complet", + "media_limitedAccess_choosePhotoAgain": "Choisir à nouveau la photo", "attrLabel_hose_length_m": "Longueur du flexible", "attrLabel_hose_type": "Type de flexible", "attrLabel_plate_material": "Matériau de la plaque", diff --git a/lib/l10n/arb/app_he.arb b/lib/l10n/arb/app_he.arb index c0b7908e3c..afbee1b7dc 100644 --- a/lib/l10n/arb/app_he.arb +++ b/lib/l10n/arb/app_he.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "הכונן אינו מחובר", "media_unavailablePlaceholder_stillFetching": "עדיין נטען. הקש כדי לנסות שוב.", "media_unavailablePlaceholder_accessDenied": "אין גישה לספריית התמונות", + "media_unavailablePlaceholder_limitedAccess": "לא בין התמונות המורשות", + "media_limitedAccess_allowFullAccess": "אפשר גישה מלאה", + "media_limitedAccess_choosePhotoAgain": "בחר את התמונה שוב", "attrLabel_hose_length_m": "אורך הצינור", "attrLabel_hose_type": "סוג צינור", "attrLabel_plate_material": "חומר הפלטה", diff --git a/lib/l10n/arb/app_hu.arb b/lib/l10n/arb/app_hu.arb index 68df187159..ccc387a148 100644 --- a/lib/l10n/arb/app_hu.arb +++ b/lib/l10n/arb/app_hu.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "A kötet nincs csatlakoztatva", "media_unavailablePlaceholder_stillFetching": "Még töltődik. Koppintson az újrapróbálkozáshoz.", "media_unavailablePlaceholder_accessDenied": "Nincs hozzáférés a fotókönyvtárhoz", + "media_unavailablePlaceholder_limitedAccess": "Nincs az engedélyezett fotók között", + "media_limitedAccess_allowFullAccess": "Teljes hozzáférés engedélyezése", + "media_limitedAccess_choosePhotoAgain": "Fotó újbóli kiválasztása", "attrLabel_hose_length_m": "Tömlő hossza", "attrLabel_hose_type": "Tömlő típusa", "attrLabel_plate_material": "Lemez anyaga", diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index 260e08e62f..1d25079957 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volume non montato", "media_unavailablePlaceholder_stillFetching": "Ancora in caricamento. Tocca per riprovare.", "media_unavailablePlaceholder_accessDenied": "Nessun accesso alla libreria foto", + "media_unavailablePlaceholder_limitedAccess": "Non tra le foto consentite", + "media_limitedAccess_allowFullAccess": "Consenti accesso completo", + "media_limitedAccess_choosePhotoAgain": "Scegli di nuovo la foto", "attrLabel_hose_length_m": "Lunghezza della frusta", "attrLabel_hose_type": "Tipo di frusta", "attrLabel_plate_material": "Materiale della piastra", diff --git a/lib/l10n/arb/app_localizations.dart b/lib/l10n/arb/app_localizations.dart index 9b53da3c1e..461aca48e3 100644 --- a/lib/l10n/arb/app_localizations.dart +++ b/lib/l10n/arb/app_localizations.dart @@ -45324,6 +45324,24 @@ abstract class AppLocalizations { /// **'No photo library access'** String get media_unavailablePlaceholder_accessDenied; + /// No description provided for @media_unavailablePlaceholder_limitedAccess. + /// + /// In en, this message translates to: + /// **'Not in your allowed photos'** + String get media_unavailablePlaceholder_limitedAccess; + + /// No description provided for @media_limitedAccess_allowFullAccess. + /// + /// In en, this message translates to: + /// **'Allow full access'** + String get media_limitedAccess_allowFullAccess; + + /// No description provided for @media_limitedAccess_choosePhotoAgain. + /// + /// In en, this message translates to: + /// **'Choose photo again'** + String get media_limitedAccess_choosePhotoAgain; + /// No description provided for @attrLabel_hose_length_m. /// /// In en, this message translates to: diff --git a/lib/l10n/arb/app_localizations_ar.dart b/lib/l10n/arb/app_localizations_ar.dart index a99fbb8735..9a080120a7 100644 --- a/lib/l10n/arb/app_localizations_ar.dart +++ b/lib/l10n/arb/app_localizations_ar.dart @@ -27944,6 +27944,16 @@ class AppLocalizationsAr extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'لا يوجد وصول إلى مكتبة الصور'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'ليست ضمن الصور المسموح بها'; + + @override + String get media_limitedAccess_allowFullAccess => 'السماح بالوصول الكامل'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'اختيار الصورة مجددًا'; + @override String get attrLabel_hose_length_m => 'طول الخرطوم'; diff --git a/lib/l10n/arb/app_localizations_de.dart b/lib/l10n/arb/app_localizations_de.dart index eff1a05a80..6600252f67 100644 --- a/lib/l10n/arb/app_localizations_de.dart +++ b/lib/l10n/arb/app_localizations_de.dart @@ -28279,6 +28279,16 @@ class AppLocalizationsDe extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Kein Zugriff auf die Fotomediathek'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Nicht unter den freigegebenen Fotos'; + + @override + String get media_limitedAccess_allowFullAccess => 'Vollzugriff erlauben'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'Foto erneut auswählen'; + @override String get attrLabel_hose_length_m => 'Schlauchlänge'; diff --git a/lib/l10n/arb/app_localizations_en.dart b/lib/l10n/arb/app_localizations_en.dart index 6c071576da..5af18149e5 100644 --- a/lib/l10n/arb/app_localizations_en.dart +++ b/lib/l10n/arb/app_localizations_en.dart @@ -27872,6 +27872,16 @@ class AppLocalizationsEn extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'No photo library access'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Not in your allowed photos'; + + @override + String get media_limitedAccess_allowFullAccess => 'Allow full access'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'Choose photo again'; + @override String get attrLabel_hose_length_m => 'Hose length'; diff --git a/lib/l10n/arb/app_localizations_es.dart b/lib/l10n/arb/app_localizations_es.dart index 619e818072..1b0ab08b8c 100644 --- a/lib/l10n/arb/app_localizations_es.dart +++ b/lib/l10n/arb/app_localizations_es.dart @@ -28358,6 +28358,16 @@ class AppLocalizationsEs extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Sin acceso a la fototeca'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'No está entre las fotos permitidas'; + + @override + String get media_limitedAccess_allowFullAccess => 'Permitir acceso completo'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'Volver a elegir la foto'; + @override String get attrLabel_hose_length_m => 'Longitud del latiguillo'; diff --git a/lib/l10n/arb/app_localizations_fr.dart b/lib/l10n/arb/app_localizations_fr.dart index 6594148db0..60014d6c32 100644 --- a/lib/l10n/arb/app_localizations_fr.dart +++ b/lib/l10n/arb/app_localizations_fr.dart @@ -28432,6 +28432,18 @@ class AppLocalizationsFr extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Aucun accès à la photothèque'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Hors des photos autorisées'; + + @override + String get media_limitedAccess_allowFullAccess => + 'Autoriser l\'accès complet'; + + @override + String get media_limitedAccess_choosePhotoAgain => + 'Choisir à nouveau la photo'; + @override String get attrLabel_hose_length_m => 'Longueur du flexible'; diff --git a/lib/l10n/arb/app_localizations_he.dart b/lib/l10n/arb/app_localizations_he.dart index 08b04daca1..b58cd2217c 100644 --- a/lib/l10n/arb/app_localizations_he.dart +++ b/lib/l10n/arb/app_localizations_he.dart @@ -27642,6 +27642,16 @@ class AppLocalizationsHe extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'אין גישה לספריית התמונות'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'לא בין התמונות המורשות'; + + @override + String get media_limitedAccess_allowFullAccess => 'אפשר גישה מלאה'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'בחר את התמונה שוב'; + @override String get attrLabel_hose_length_m => 'אורך הצינור'; diff --git a/lib/l10n/arb/app_localizations_hu.dart b/lib/l10n/arb/app_localizations_hu.dart index 01006fa755..5682f18a37 100644 --- a/lib/l10n/arb/app_localizations_hu.dart +++ b/lib/l10n/arb/app_localizations_hu.dart @@ -28222,6 +28222,17 @@ class AppLocalizationsHu extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Nincs hozzáférés a fotókönyvtárhoz'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Nincs az engedélyezett fotók között'; + + @override + String get media_limitedAccess_allowFullAccess => + 'Teljes hozzáférés engedélyezése'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'Fotó újbóli kiválasztása'; + @override String get attrLabel_hose_length_m => 'Tömlő hossza'; diff --git a/lib/l10n/arb/app_localizations_it.dart b/lib/l10n/arb/app_localizations_it.dart index 6418182905..0e55bb886c 100644 --- a/lib/l10n/arb/app_localizations_it.dart +++ b/lib/l10n/arb/app_localizations_it.dart @@ -28336,6 +28336,16 @@ class AppLocalizationsIt extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Nessun accesso alla libreria foto'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Non tra le foto consentite'; + + @override + String get media_limitedAccess_allowFullAccess => 'Consenti accesso completo'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'Scegli di nuovo la foto'; + @override String get attrLabel_hose_length_m => 'Lunghezza della frusta'; diff --git a/lib/l10n/arb/app_localizations_nl.dart b/lib/l10n/arb/app_localizations_nl.dart index a9ed7bce4d..04299e3d89 100644 --- a/lib/l10n/arb/app_localizations_nl.dart +++ b/lib/l10n/arb/app_localizations_nl.dart @@ -28124,6 +28124,17 @@ class AppLocalizationsNl extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Geen toegang tot fotobibliotheek'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Niet bij de toegestane foto\'s'; + + @override + String get media_limitedAccess_allowFullAccess => + 'Volledige toegang toestaan'; + + @override + String get media_limitedAccess_choosePhotoAgain => 'Foto opnieuw kiezen'; + @override String get attrLabel_hose_length_m => 'Slanglengte'; diff --git a/lib/l10n/arb/app_localizations_pt.dart b/lib/l10n/arb/app_localizations_pt.dart index 80601b7c51..0fdd0bdbbf 100644 --- a/lib/l10n/arb/app_localizations_pt.dart +++ b/lib/l10n/arb/app_localizations_pt.dart @@ -28335,6 +28335,17 @@ class AppLocalizationsPt extends AppLocalizations { String get media_unavailablePlaceholder_accessDenied => 'Sem acesso à biblioteca de fotos'; + @override + String get media_unavailablePlaceholder_limitedAccess => + 'Fora das fotos permitidas'; + + @override + String get media_limitedAccess_allowFullAccess => 'Permitir acesso total'; + + @override + String get media_limitedAccess_choosePhotoAgain => + 'Escolher a foto novamente'; + @override String get attrLabel_hose_length_m => 'Comprimento da mangueira'; diff --git a/lib/l10n/arb/app_localizations_zh.dart b/lib/l10n/arb/app_localizations_zh.dart index 5de615cbf6..a75aedf6f4 100644 --- a/lib/l10n/arb/app_localizations_zh.dart +++ b/lib/l10n/arb/app_localizations_zh.dart @@ -26830,6 +26830,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get media_unavailablePlaceholder_accessDenied => '无照片库访问权限'; + @override + String get media_unavailablePlaceholder_limitedAccess => '不在已允许的照片中'; + + @override + String get media_limitedAccess_allowFullAccess => '允许完全访问'; + + @override + String get media_limitedAccess_choosePhotoAgain => '重新选择照片'; + @override String get attrLabel_hose_length_m => '软管长度'; diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 387cd290af..4e35b1623d 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volume niet gekoppeld", "media_unavailablePlaceholder_stillFetching": "Nog aan het laden. Tik om opnieuw te proberen.", "media_unavailablePlaceholder_accessDenied": "Geen toegang tot fotobibliotheek", + "media_unavailablePlaceholder_limitedAccess": "Niet bij de toegestane foto's", + "media_limitedAccess_allowFullAccess": "Volledige toegang toestaan", + "media_limitedAccess_choosePhotoAgain": "Foto opnieuw kiezen", "attrLabel_hose_length_m": "Slanglengte", "attrLabel_hose_type": "Slangtype", "attrLabel_plate_material": "Plaatmateriaal", diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 0d09c8bc08..c35a02b398 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "Volume não montado", "media_unavailablePlaceholder_stillFetching": "Ainda a carregar. Toque para tentar novamente.", "media_unavailablePlaceholder_accessDenied": "Sem acesso à biblioteca de fotos", + "media_unavailablePlaceholder_limitedAccess": "Fora das fotos permitidas", + "media_limitedAccess_allowFullAccess": "Permitir acesso total", + "media_limitedAccess_choosePhotoAgain": "Escolher a foto novamente", "attrLabel_hose_length_m": "Comprimento da mangueira", "attrLabel_hose_type": "Tipo de mangueira", "attrLabel_plate_material": "Material da placa", diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index 8b2f77d618..38b685787c 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -8180,6 +8180,9 @@ "media_unavailablePlaceholder_volumeOffline": "卷未挂载", "media_unavailablePlaceholder_stillFetching": "仍在加载。点按重试。", "media_unavailablePlaceholder_accessDenied": "无照片库访问权限", + "media_unavailablePlaceholder_limitedAccess": "不在已允许的照片中", + "media_limitedAccess_allowFullAccess": "允许完全访问", + "media_limitedAccess_choosePhotoAgain": "重新选择照片", "attrLabel_hose_length_m": "软管长度", "attrLabel_hose_type": "软管类型", "attrLabel_plate_material": "背板材质", diff --git a/test/features/media/presentation/widgets/limited_access_actions_test.dart b/test/features/media/presentation/widgets/limited_access_actions_test.dart new file mode 100644 index 0000000000..29b8bd1efe --- /dev/null +++ b/test/features/media/presentation/widgets/limited_access_actions_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/photo_access_actions.dart'; +import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/limited_access_actions.dart'; +import 'package:submersion/l10n/arb/app_localizations.dart'; + +class _RecordingActions implements PhotoAccessActions { + final calls = []; + Object? error; + + @override + Future openSettings() async { + calls.add('settings'); + if (error != null) throw error!; + } + + @override + Future chooseMorePhotos() async { + calls.add('choose'); + if (error != null) throw error!; + } +} + +/// A photo outside the user's limited selection offers the two ways back: +/// full access in the system settings, or adding it to the selection +/// (media sync program spec 6.3). +void main() { + late _RecordingActions actions; + late int changes; + + setUp(() { + actions = _RecordingActions(); + changes = 0; + }); + + Future pump(WidgetTester tester) => tester.pumpWidget( + ProviderScope( + overrides: [photoAccessActionsProvider.overrideWithValue(actions)], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: LimitedAccessActions(onChanged: () => changes++)), + ), + ), + ); + + testWidgets('Allow full access opens the settings, then refreshes', ( + tester, + ) async { + await pump(tester); + + await tester.tap(find.text('Allow full access')); + await tester.pumpAndSettle(); + + expect(actions.calls, ['settings']); + expect(changes, 1); + }); + + testWidgets('Choose photo again opens the selection, then refreshes', ( + tester, + ) async { + await pump(tester); + + await tester.tap(find.text('Choose photo again')); + await tester.pumpAndSettle(); + + expect(actions.calls, ['choose']); + expect(changes, 1); + }); + + // A platform that cannot open the sheet (an older OS) must not surface + // an exception from a button. + testWidgets('a failing action is contained, and still refreshes', ( + tester, + ) async { + actions.error = StateError('unsupported'); + await pump(tester); + + await tester.tap(find.text('Choose photo again')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(changes, 1); + }); +} diff --git a/test/features/media/presentation/widgets/media_info_panel_test.dart b/test/features/media/presentation/widgets/media_info_panel_test.dart index 5aa2d6c3b6..8a6d95c57b 100644 --- a/test/features/media/presentation/widgets/media_info_panel_test.dart +++ b/test/features/media/presentation/widgets/media_info_panel_test.dart @@ -6,6 +6,7 @@ import 'package:submersion/features/media/data/services/media_health_report.dart import 'package:submersion/features/media/data/services/media_health_reporter.dart'; import 'package:submersion/features/media/data/services/media_item_verifier.dart'; import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; +import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; import 'package:submersion/features/media_store/data/media_transfer_queue_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -234,6 +235,49 @@ void main() { }); }); + // Under limited photo access a gallery photo may be outside what the user + // allowed, so the panel offers the two ways back (spec 6.3). + group('Limited photo access', () { + testWidgets('a gallery row offers full access and the selection', ( + tester, + ) async { + await pump( + tester, + _item(), + extra: [galleryAccessLimitedProvider.overrideWith((ref) async => true)], + ); + + expect(find.text('Allow full access'), findsOneWidget); + expect(find.text('Choose photo again'), findsOneWidget); + }); + + testWidgets('full access offers neither', (tester) async { + await pump( + tester, + _item(), + extra: [ + galleryAccessLimitedProvider.overrideWith((ref) async => false), + ], + ); + + expect(find.text('Allow full access'), findsNothing); + }); + + testWidgets('a file row offers neither', (tester) async { + await pump( + tester, + _item( + sourceType: MediaSourceType.localFile, + platformAssetId: null, + localPath: 'reef.jpg', + ), + extra: [galleryAccessLimitedProvider.overrideWith((ref) async => true)], + ); + + expect(find.text('Allow full access'), findsNothing); + }); + }); + group('Origin block', () { testWidgets('renders the source label and the pointer', (tester) async { await pump(tester, _item()); diff --git a/test/features/media/presentation/widgets/media_item_view_test.dart b/test/features/media/presentation/widgets/media_item_view_test.dart index dbf80745b5..65720246a1 100644 --- a/test/features/media/presentation/widgets/media_item_view_test.dart +++ b/test/features/media/presentation/widgets/media_item_view_test.dart @@ -366,4 +366,58 @@ void main() { await tester.pumpAndSettle(); expect(stub.resolveCalls, 2); }); + + group('a photo outside a limited selection', () { + const limited = UnavailableData( + kind: UnavailableKind.accessDenied, + limitedAccess: true, + ); + + // The viewer shows the ways back; a grid tile has no room for them. + testWidgets('the viewer offers full access and the selection', ( + tester, + ) async { + final resolver = _StubResolver(limited, MediaSourceType.platformGallery); + await tester.pumpWidget( + _wrap( + resolver: resolver, + child: MediaItemView(item: _item(), showAccessActions: true), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Allow full access'), findsOneWidget); + expect(find.text('Choose photo again'), findsOneWidget); + }); + + testWidgets('a grid tile shows the placeholder alone', (tester) async { + final resolver = _StubResolver(limited, MediaSourceType.platformGallery); + await tester.pumpWidget( + _wrap( + resolver: resolver, + child: MediaItemView(item: _item(), thumbnail: true), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Not in your allowed photos'), findsOneWidget); + expect(find.text('Allow full access'), findsNothing); + }); + + testWidgets('plain denied access offers no actions', (tester) async { + final resolver = _StubResolver( + const UnavailableData(kind: UnavailableKind.accessDenied), + MediaSourceType.platformGallery, + ); + await tester.pumpWidget( + _wrap( + resolver: resolver, + child: MediaItemView(item: _item(), showAccessActions: true), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Allow full access'), findsNothing); + }); + }); } diff --git a/test/features/media/presentation/widgets/unavailable_media_placeholder_test.dart b/test/features/media/presentation/widgets/unavailable_media_placeholder_test.dart index ad7951130e..3f585ecbef 100644 --- a/test/features/media/presentation/widgets/unavailable_media_placeholder_test.dart +++ b/test/features/media/presentation/widgets/unavailable_media_placeholder_test.dart @@ -109,4 +109,32 @@ void main() { expect(find.text('Still loading. Tap to retry.'), findsOneWidget); expect(find.byIcon(Icons.hourglass_empty), findsOneWidget); }); + + // The photo may be in the library, outside what the user allowed + // (spec 6.3): not the same message as no access at all. + testWidgets('renders a limited selection distinctly', (tester) async { + await tester.pumpWidget( + _wrap( + const UnavailableMediaPlaceholder( + data: UnavailableData( + kind: UnavailableKind.accessDenied, + limitedAccess: true, + ), + ), + ), + ); + expect(find.text('Not in your allowed photos'), findsOneWidget); + expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget); + }); + + testWidgets('renders plain denied access as before', (tester) async { + await tester.pumpWidget( + _wrap( + const UnavailableMediaPlaceholder( + data: UnavailableData(kind: UnavailableKind.accessDenied), + ), + ), + ); + expect(find.text('No photo library access'), findsOneWidget); + }); } From 31cfb6935ae16181e499c6be70d491f06d19e766 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:43:35 -0400 Subject: [PATCH 06/12] docs(spec): record slice 9's decisions in 6.3 --- .../2026-09-23-media-sync-phase2-android-limited.md | 9 +++++++++ .../specs/2026-09-18-media-sync-program-design.md | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md index 9d1c6482bf..65aeb4ab3a 100644 --- a/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md +++ b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md @@ -166,3 +166,12 @@ - [ ] Mutation-check each guard (limited exits, query-failure verdict, `currentPermission` in resolution, the grant-lost classification, the peer-row skip, `verify`'s mapping, the actions' `showAccessActions` gate, the info panel's source-type gate). Each mutation must compile and fail its named test. - [ ] `dart format .`, `flutter analyze`, `flutter test`, and `test/architecture` explicitly. - [ ] Commit `docs(spec): record slice 9's decisions in 6.3`. PR body: `Closes #2121`, `Refs #1625`, `Part of #2090`. + +## Execution notes (2026-09-23) + +- Task 1: adding `currentPermission` to `PhotoPickerService` meant a one-line override in ten hand-written test fakes that `implement` it (the two that extend `Fake` needed none); each returns what its `checkPermission` does, so their behaviour is unchanged. The mockito stubs in `asset_resolution_service_test.dart` moved to `currentPermission`. +- Task 2: under limited access both "not found" exits return `accessDenied` flagged `limitedAccess` (the no-candidates exit and the one after tier 3), each with its own test. The thumbnail path in `PlatformGalleryResolver` keeps the re-derived result, not just its status. +- Task 3: the Android branch of `LocalFileResolver` is now behind an injectable `readsContentUris`, so it runs in the Linux test shards instead of being `coverage:ignore`d. +- Task 4: the info panel reuses `LimitedAccessActions` inside its actions `Wrap`. The panel's `galleryAccessLimitedProvider` swallows a platform failure as false, so the existing panel tests, which do not override it, are unchanged. +- Mutation pass: 12 mutations, each compiling and failing its named test: the non-prompting read, the query-failure verdict, both limited exits (S7 and the tier-3 test), the resolver's pass-through, the grant-lost classification, the peer-row skip, serving a recovered photo, `verify`'s mapping, the viewer-only actions gate, the panel's gallery-only gate, and the limited placeholder message. + diff --git a/docs/superpowers/specs/2026-09-18-media-sync-program-design.md b/docs/superpowers/specs/2026-09-18-media-sync-program-design.md index c9a43d4191..0912bbcd74 100644 --- a/docs/superpowers/specs/2026-09-18-media-sync-program-design.md +++ b/docs/superpowers/specs/2026-09-18-media-sync-program-design.md @@ -460,6 +460,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 From 1032bc15744d1a1da6e0ddf0a09a04fde85b8be1 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 21:56:57 -0400 Subject: [PATCH 07/12] refactor(media): the cloud id backfill reads access through currentPermission Slice 8's provider chose its non-prompting read by platform type; the interface now carries currentPermission, as the origin backfill uses. --- .../providers/gallery_cloud_id_backfill_provider.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/features/media/presentation/providers/gallery_cloud_id_backfill_provider.dart b/lib/features/media/presentation/providers/gallery_cloud_id_backfill_provider.dart index f0c608300f..b3b171d9e1 100644 --- a/lib/features/media/presentation/providers/gallery_cloud_id_backfill_provider.dart +++ b/lib/features/media/presentation/providers/gallery_cloud_id_backfill_provider.dart @@ -5,7 +5,6 @@ import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/media/data/services/cloud_identifier_source.dart'; import 'package:submersion/features/media/data/services/gallery_cloud_id_backfill.dart'; -import 'package:submersion/features/media/data/services/photo_picker_service_mobile.dart'; import 'package:submersion/features/media/presentation/providers/media_providers.dart'; import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; @@ -26,9 +25,7 @@ final galleryCloudIdBackfillProvider = Provider Function()>((ref) { cloudIdentifiers: const PhotoManagerCloudIdentifierSource(), photos: photos, // Read, never asked: this runs after a sync, unasked. - permissionStatus: photos is PhotoPickerServiceMobile - ? photos.currentPermission - : photos.checkPermission, + permissionStatus: photos.currentPermission, deviceId: () => SyncRepository().getDeviceId(), prefs: prefs, assetCache: ref.read(localAssetCacheRepositoryProvider), From 64e044a03fda715c53f8a39bd10e02a7f628169f Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 23:09:48 -0400 Subject: [PATCH 08/12] fix(media): inconclusive stays inconclusive on every path out of reach A cached gallery mapping whose asset stopped reading (dropped from a limited selection, or re-indexed) fell straight to the missing verdict; resolve and verify now search again first and keep an accessDenied answer. The lost-grant search reduced a search that could not look to null, so a failed read became notFound; librarySearchOutcome keeps it accessDenied. The site media viewer offers the access actions too, and Allow full access refreshes when the app resumes, since opening the settings returns at once. --- ...09-23-media-sync-phase2-android-limited.md | 1 + .../resolvers/platform_gallery_resolver.dart | 37 +++++- .../data/services/library_search_outcome.dart | 33 +++++ .../pages/site_media_viewer_page.dart | 8 +- .../providers/media_resolver_providers.dart | 16 +-- .../widgets/limited_access_actions.dart | 98 ++++++++++---- .../local_file_resolver_content_uri_test.dart | 11 ++ .../platform_gallery_resolver_stale_test.dart | 122 ++++++++++++++++++ .../services/library_search_outcome_test.dart | 72 +++++++++++ .../pages/site_media_viewer_page_test.dart | 11 ++ .../widgets/limited_access_actions_test.dart | 26 +++- 11 files changed, 393 insertions(+), 42 deletions(-) create mode 100644 lib/features/media/data/services/library_search_outcome.dart create mode 100644 test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart create mode 100644 test/features/media/data/services/library_search_outcome_test.dart diff --git a/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md index 65aeb4ab3a..853317e45b 100644 --- a/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md +++ b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md @@ -175,3 +175,4 @@ - Task 4: the info panel reuses `LimitedAccessActions` inside its actions `Wrap`. The panel's `galleryAccessLimitedProvider` swallows a platform failure as false, so the existing panel tests, which do not override it, are unchanged. - Mutation pass: 12 mutations, each compiling and failing its named test: the non-prompting read, the query-failure verdict, both limited exits (S7 and the tier-3 test), the resolver's pass-through, the grant-lost classification, the peer-row skip, serving a recovered photo, `verify`'s mapping, the viewer-only actions gate, the panel's gallery-only gate, and the limited placeholder message. +- Review round (PR #2313): a cached mapping whose asset stops reading is re-searched in `resolve` and `verify` before `_missing`, keeping an inconclusive answer; the lost-grant search maps its result through `librarySearchOutcome`, so a search that could not look stays `accessDenied` instead of collapsing to null (and then `notFound`); the site media viewer passes `showAccessActions`; and "Allow full access" refreshes when the app resumes, since opening the settings returns at once. Seven more mutations, each compiling and failing its named test. diff --git a/lib/features/media/data/resolvers/platform_gallery_resolver.dart b/lib/features/media/data/resolvers/platform_gallery_resolver.dart index 5ed144df6e..c3cb30bc3e 100644 --- a/lib/features/media/data/resolvers/platform_gallery_resolver.dart +++ b/lib/features/media/data/resolvers/platform_gallery_resolver.dart @@ -162,8 +162,28 @@ class PlatformGalleryResolver implements MediaSourceResolver { 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 @@ -258,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; diff --git a/lib/features/media/data/services/library_search_outcome.dart b/lib/features/media/data/services/library_search_outcome.dart new file mode 100644 index 0000000000..0af4a6ba9f --- /dev/null +++ b/lib/features/media/data/services/library_search_outcome.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; + +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; + +/// What a photo library search for a file whose pointer stopped reading +/// comes to (media sync program spec 6.3): +/// +/// * the photo's bytes, when the search found it and it reads; +/// * an inconclusive `accessDenied` (flagged when the view was a limited +/// selection), when the search could not look at all; +/// * null, when it looked and found nothing that reads. +/// +/// Only null lets the caller fall back to its own verdict, which for a +/// failed read on the linking device is notFound, so a search that could +/// not look must never collapse into it. +Future librarySearchOutcome( + ResolutionResult found, + Future Function(String assetId) originBytes, +) async { + if (found.status == ResolutionStatus.accessDenied) { + return UnavailableData( + kind: UnavailableKind.accessDenied, + limitedAccess: found.limitedAccess, + ); + } + final id = found.localAssetId; + if (id == null) return null; + final bytes = await originBytes(id); + return bytes == null + ? null + : BytesData(bytes: bytes, servedFrom: ServedFrom.platformGallery); +} diff --git a/lib/features/media/presentation/pages/site_media_viewer_page.dart b/lib/features/media/presentation/pages/site_media_viewer_page.dart index c70ab04e75..350710bba7 100644 --- a/lib/features/media/presentation/pages/site_media_viewer_page.dart +++ b/lib/features/media/presentation/pages/site_media_viewer_page.dart @@ -326,7 +326,13 @@ class _MediaGalleryPager extends StatelessWidget { return PhotoViewGalleryPageOptions.customChild( minScale: PhotoViewComputedScale.contained, maxScale: PhotoViewComputedScale.covered * 3.0, - child: MediaItemView(item: item, fit: BoxFit.contain), + // A full-screen pager: a photo outside a limited selection offers + // the ways back here, as in the dive viewer (spec 6.3). + child: MediaItemView( + item: item, + fit: BoxFit.contain, + showAccessActions: true, + ), ); }, loadingBuilder: (context, event) => diff --git a/lib/features/media/presentation/providers/media_resolver_providers.dart b/lib/features/media/presentation/providers/media_resolver_providers.dart index 8e87fb4683..76a9b77668 100644 --- a/lib/features/media/presentation/providers/media_resolver_providers.dart +++ b/lib/features/media/presentation/providers/media_resolver_providers.dart @@ -14,6 +14,7 @@ import 'package:submersion/features/media/data/resolvers/platform_gallery_resolv import 'package:submersion/features/media/data/resolvers/signature_resolver.dart'; import 'package:submersion/features/media/data/services/dive_link_matcher.dart'; import 'package:submersion/features/media/data/services/exif_extractor.dart'; +import 'package:submersion/features/media/data/services/library_search_outcome.dart'; import 'package:submersion/features/media/data/services/media_item_verifier.dart'; import 'package:submersion/features/media/data/services/media_verification_sweep.dart'; import 'package:submersion/features/media/data/services/gallery_thumbnail_cache.dart'; @@ -127,17 +128,10 @@ final localFileResolverProvider = Provider((ref) { ref.read(peerDeviceNameStoreProvider).nameFor(id), // A content URI that stopped reading on Android is searched for in the // photo library by metadata before anything is decided (spec 6.3). - findInLibrary: (item) async { - final found = await ref - .read(assetResolutionServiceProvider) - .findInLibrary(item); - final id = found.localAssetId; - if (id == null) return null; - final bytes = await const PhotoManagerAssetReader().originBytes(id); - return bytes == null - ? null - : BytesData(bytes: bytes, servedFrom: ServedFrom.platformGallery); - }, + findInLibrary: (item) async => librarySearchOutcome( + await ref.read(assetResolutionServiceProvider).findInLibrary(item), + const PhotoManagerAssetReader().originBytes, + ), ); // The resolver's fetch gate holds timers that outlive the fetch they bound, // so a rebuild or a container teardown with a tile still resolving would diff --git a/lib/features/media/presentation/widgets/limited_access_actions.dart b/lib/features/media/presentation/widgets/limited_access_actions.dart index 6eacd15719..ef7a3d0b6b 100644 --- a/lib/features/media/presentation/widgets/limited_access_actions.dart +++ b/lib/features/media/presentation/widgets/limited_access_actions.dart @@ -8,48 +8,94 @@ import 'package:submersion/l10n/l10n_extension.dart'; /// "Allow full access" and "Choose photo again", for a photo outside the /// user's limited selection (media sync program spec 6.3). Each hands off -/// to the system and calls [onChanged] when the user comes back, so the +/// to the system, and [onChanged] fires once the user is back, so the /// caller can look for the photo again. -class LimitedAccessActions extends ConsumerWidget { +class LimitedAccessActions extends ConsumerStatefulWidget { const LimitedAccessActions({super.key, required this.onChanged}); - /// Called after either action returns, whether or not it succeeded: - /// the user may have changed access in the meantime. + /// Called once the user is back from either action, whether or not it + /// succeeded: they may have changed access in the meantime. final VoidCallback onChanged; @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = context.l10n; - final actions = ref.watch(photoAccessActionsProvider); - - Future run(Future Function() action) async { - try { - await action(); - } on Object catch (e, stackTrace) { - // An OS without the limited-selection sheet, or a platform channel - // failure. The buttons are an offer; a failure is logged, not shown. - LoggerService.forClass( - LimitedAccessActions, - category: LogCategory.media, - ).warning( - 'Photo access action failed', - error: e, - stackTrace: stackTrace, - ); - } - if (context.mounted) onChanged(); + ConsumerState createState() => + _LimitedAccessActionsState(); +} + +class _LimitedAccessActionsState extends ConsumerState { + /// Armed while the user is in the system settings. Opening them returns at + /// once, with the user still there, so the refresh waits for the app to + /// come back to the foreground, once per trip. + AppLifecycleListener? _returning; + + final _log = LoggerService.forClass( + LimitedAccessActions, + category: LogCategory.media, + ); + + @override + void dispose() { + _returning?.dispose(); + super.dispose(); + } + + void _disarm() { + _returning?.dispose(); + _returning = null; + } + + void _changed() { + if (mounted) widget.onChanged(); + } + + Future _openSettings() async { + _disarm(); + // Armed before the hand-off, so a quick return cannot slip past it. + _returning = AppLifecycleListener( + onResume: () { + _disarm(); + _changed(); + }, + ); + try { + await ref.read(photoAccessActionsProvider).openSettings(); + } on Object catch (e, stackTrace) { + // The settings never opened, so there is no return to wait for. + _log.warning('Could not open settings', error: e, stackTrace: stackTrace); + _disarm(); + _changed(); } + } + Future _chooseMorePhotos() async { + try { + // Returns when the selection sheet closes. + await ref.read(photoAccessActionsProvider).chooseMorePhotos(); + } on Object catch (e, stackTrace) { + // An OS without the limited-selection sheet, or a platform channel + // failure. The buttons are an offer; a failure is logged, not shown. + _log.warning( + 'Could not open the photo selection', + error: e, + stackTrace: stackTrace, + ); + } + _changed(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; return Wrap( alignment: WrapAlignment.center, spacing: 8, children: [ TextButton( - onPressed: () => run(actions.openSettings), + onPressed: _openSettings, child: Text(l10n.media_limitedAccess_allowFullAccess), ), TextButton( - onPressed: () => run(actions.chooseMorePhotos), + onPressed: _chooseMorePhotos, child: Text(l10n.media_limitedAccess_choosePhotoAgain), ), ], diff --git a/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart index 444cfca642..8bddea80d1 100644 --- a/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart +++ b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart @@ -83,6 +83,17 @@ void main() { expect(searches, 1); }); + // The search could not look (no permission, a failed query, a limited + // selection): a failed read is then inconclusive too, not notFound. + test('an inconclusive search keeps a failed read inconclusive', () async { + final data = await resolver( + 'READ_FAILED', + found: const UnavailableData(kind: UnavailableKind.accessDenied), + ).resolve(row()); + + expect((data as UnavailableData).kind, UnavailableKind.accessDenied); + }); + // Another device's content URI never had a grant here, so it is not a // lost grant and not worth a library search per render. test('another device\'s content URI is not searched', () async { diff --git a/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart new file mode 100644 index 0000000000..9640eb3cdf --- /dev/null +++ b/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart @@ -0,0 +1,122 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/repositories/local_asset_cache_repository.dart'; +import 'package:submersion/features/media/data/resolvers/platform_gallery_resolver.dart'; +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; +import 'package:submersion/features/media/domain/entities/media_item.dart'; +import 'package:submersion/features/media/domain/entities/media_source_type.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; +import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; + +import '../../../../helpers/fake_photo_picker_service.dart'; + +/// Hands out a cached mapping whose asset no longer reads, then [_again] +/// when the resolver drops it and searches again. +class _StaleCacheService extends AssetResolutionService { + _StaleCacheService(this._again) + : super( + cacheRepository: LocalAssetCacheRepository(), + photoPickerService: FakePhotoPickerService(), + ); + + final ResolutionResult _again; + int reresolves = 0; + + @override + Future resolveAssetId(MediaItem item) async => + const ResolutionResult( + localAssetId: 'cached', + status: ResolutionStatus.resolved, + ); + + @override + Future reresolve(MediaItem item) async { + reresolves++; + return _again; + } +} + +/// A cached mapping is trusted without re-proving it, so a photo can stop +/// reading under it: dropped from a limited selection, or re-indexed. That +/// is a reason to search again, never proof the photo is gone (media sync +/// program spec 6.3). +void main() { + late FakePhotoPickerService library; + + setUp(() => library = FakePhotoPickerService()); + + /// Linked on this device, so a genuine miss here would be notFound. + MediaItem row() => MediaItem( + id: 'x', + mediaType: MediaType.photo, + sourceType: MediaSourceType.platformGallery, + platformAssetId: 'A-1', + originDeviceId: 'this-device', + takenAt: DateTime.utc(2024, 1, 1), + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 1), + ); + + PlatformGalleryResolver resolver(AssetResolutionService service) => + PlatformGalleryResolver( + resolutionService: service, + assetReader: library, + localDeviceId: () async => 'this-device', + ); + + const limited = ResolutionResult( + status: ResolutionStatus.accessDenied, + limitedAccess: true, + ); + + test( + 'a cached photo dropped from the selection is limited, not gone', + () async { + final service = _StaleCacheService(limited); + + final data = await resolver(service).resolve(row()); + + expect((data as UnavailableData).kind, UnavailableKind.accessDenied); + expect(data.limitedAccess, isTrue); + expect(service.reresolves, 1); + }, + ); + + test('a cached photo re-found under a new id is served', () async { + library.add( + FakeGalleryAsset( + id: 'B-2', + bytes: Uint8List.fromList([5]), + takenAt: DateTime(2024), + ), + ); + final service = _StaleCacheService( + const ResolutionResult( + localAssetId: 'B-2', + status: ResolutionStatus.resolved, + ), + ); + + final data = await resolver(service).resolve(row()); + + expect((data as BytesData).bytes, [5]); + }); + + test('a cached photo the search cannot find is still missing', () async { + final service = _StaleCacheService( + const ResolutionResult(status: ResolutionStatus.unavailable), + ); + + final data = await resolver(service).resolve(row()); + + expect((data as UnavailableData).kind, UnavailableKind.notFound); + }); + + test('verify re-searches a cached photo that no longer exists', () async { + final service = _StaleCacheService(limited); + + expect(await resolver(service).verify(row()), VerifyResult.accessDenied); + expect(service.reresolves, 1); + }); +} diff --git a/test/features/media/data/services/library_search_outcome_test.dart b/test/features/media/data/services/library_search_outcome_test.dart new file mode 100644 index 0000000000..3704683e16 --- /dev/null +++ b/test/features/media/data/services/library_search_outcome_test.dart @@ -0,0 +1,72 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; +import 'package:submersion/features/media/data/services/library_search_outcome.dart'; +import 'package:submersion/features/media/domain/value_objects/media_source_data.dart'; + +/// What a library search for a file that stopped reading comes to (media +/// sync program spec 6.3). A search that could not look is inconclusive, +/// never "found nothing": the caller turns nothing into notFound. +void main() { + Future bytesFor(String id) async => + id == 'B-1' ? Uint8List.fromList([1]) : null; + + test('a search that could not look stays inconclusive', () async { + final outcome = await librarySearchOutcome( + const ResolutionResult(status: ResolutionStatus.accessDenied), + bytesFor, + ); + + final data = outcome! as UnavailableData; + expect(data.kind, UnavailableKind.accessDenied); + expect(data.limitedAccess, isFalse); + }); + + test('a limited search stays inconclusive, flagged limited', () async { + final outcome = await librarySearchOutcome( + const ResolutionResult( + status: ResolutionStatus.accessDenied, + limitedAccess: true, + ), + bytesFor, + ); + + expect((outcome! as UnavailableData).limitedAccess, isTrue); + }); + + test('a search that looked and found nothing is null', () async { + expect( + await librarySearchOutcome( + const ResolutionResult(status: ResolutionStatus.unavailable), + bytesFor, + ), + isNull, + ); + }); + + test('a found photo that reads is served', () async { + final outcome = await librarySearchOutcome( + const ResolutionResult( + localAssetId: 'B-1', + status: ResolutionStatus.resolved, + ), + bytesFor, + ); + + expect((outcome! as BytesData).bytes, [1]); + }); + + test('a found photo that does not read is null', () async { + expect( + await librarySearchOutcome( + const ResolutionResult( + localAssetId: 'B-9', + status: ResolutionStatus.resolved, + ), + bytesFor, + ), + isNull, + ); + }); +} diff --git a/test/features/media/presentation/pages/site_media_viewer_page_test.dart b/test/features/media/presentation/pages/site_media_viewer_page_test.dart index 7dfe16af21..935655c798 100644 --- a/test/features/media/presentation/pages/site_media_viewer_page_test.dart +++ b/test/features/media/presentation/pages/site_media_viewer_page_test.dart @@ -14,6 +14,7 @@ import 'package:submersion/features/media/domain/entities/media_item.dart'; import 'package:submersion/features/media/presentation/pages/site_media_viewer_page.dart'; import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; import 'package:submersion/features/media/presentation/providers/site_media_providers.dart'; +import 'package:submersion/features/media/presentation/widgets/media_item_view.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; import '../support/media_widget_harness.dart'; @@ -133,6 +134,16 @@ void main() { expect(find.text('1 / 2'), findsOneWidget); }); + // A full-screen pager, like the dive viewer: a photo outside a limited + // selection must offer the ways back here too (spec 6.3). + testWidgets('the pager offers limited-access actions', (tester) async { + await pumpViewer(tester); + + final views = tester.widgetList(find.byType(MediaItemView)); + expect(views, isNotEmpty); + expect(views.every((v) => v.showAccessActions), isTrue); + }); + testWidgets('initialMediaId selects that item rather than the first', ( tester, ) async { diff --git a/test/features/media/presentation/widgets/limited_access_actions_test.dart b/test/features/media/presentation/widgets/limited_access_actions_test.dart index 29b8bd1efe..b2f6ddd528 100644 --- a/test/features/media/presentation/widgets/limited_access_actions_test.dart +++ b/test/features/media/presentation/widgets/limited_access_actions_test.dart @@ -46,15 +46,37 @@ void main() { ), ); - testWidgets('Allow full access opens the settings, then refreshes', ( + // Opening the settings returns at once, while the user is still there: + // the refresh waits until they come back to the app. + testWidgets('Allow full access refreshes when the user returns', ( tester, ) async { await pump(tester); await tester.tap(find.text('Allow full access')); await tester.pumpAndSettle(); - expect(actions.calls, ['settings']); + expect(changes, 0, reason: 'the user is still in the settings'); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + + expect(changes, 1); + + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + expect(changes, 1, reason: 'one refresh per trip to the settings'); + }); + + testWidgets('settings that fail to open refresh at once', (tester) async { + actions.error = StateError('no settings'); + await pump(tester); + + await tester.tap(find.text('Allow full access')); + await tester.pumpAndSettle(); + expect(changes, 1); }); From 04469a8d1317f4c19a9d0d2ba5b7a302ce134733 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 23:19:04 -0400 Subject: [PATCH 09/12] fix(media): prove a cached library match before a failed read trusts it findInLibrary returned a cached asset id unproven, and a mapping gone stale (a second re-index) was then served as nothing on every render, which on the linking device reads as notFound and never clears, since resolved entries do not expire. It runs only after a read has failed, so it now checks the mapping still loads and searches again if not. Also moves _afterFailedUriRead out of _volumeOnlineOrAssumed's doc comment, which the insertion had split. --- .../data/resolvers/local_file_resolver.dart | 14 ++++---- .../services/asset_resolution_service.dart | 18 +++++++--- .../asset_resolution_permission_test.dart | 35 +++++++++++++++++++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/lib/features/media/data/resolvers/local_file_resolver.dart b/lib/features/media/data/resolvers/local_file_resolver.dart index 4895e29be9..c247cb824f 100644 --- a/lib/features/media/data/resolvers/local_file_resolver.dart +++ b/lib/features/media/data/resolvers/local_file_resolver.dart @@ -372,13 +372,6 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe { return const UnavailableData(kind: 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 - /// exists to classify. Reporting volumeOffline on a throw would be a guess; - /// assuming online falls through to the file itself, which is what this - /// resolver did before the probe was hoisted ahead of it, and lets the - /// existing exists() / open() path produce the real answer. /// 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 @@ -406,6 +399,13 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe { ); } + /// [_volumeOnline], with a probe that itself failed treated as online. + /// + /// The probe is a filesystem call and can throw on the exact mounts it + /// exists to classify. Reporting volumeOffline on a throw would be a guess; + /// assuming online falls through to the file itself, which is what this + /// resolver did before the probe was hoisted ahead of it, and lets the + /// existing exists() / open() path produce the real answer. Future _volumeOnlineOrAssumed(String path) async { try { return await _volumeOnline(path); diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index e19a5581c6..01c6847a48 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -204,16 +204,26 @@ class AssetResolutionService { /// grant was lost, which is usually still in the library (media sync /// program spec 6.3). Needs no stored asset id. Honors the same cache and /// backoff as [resolveAssetId], and shares its in-flight searches. + /// + /// Unlike [resolveAssetId], a cached mapping is proven before it is + /// trusted. This runs only after a read has already failed, so the check + /// costs nothing on the hot path, and a mapping gone stale (a second + /// re-index) would otherwise be served as nothing on every render, which + /// reads as notFound on the linking device. Future findInLibrary(MediaItem item) async { if (!_photoPickerService.supportsGalleryBrowsing) { return const ResolutionResult(status: ResolutionStatus.unavailable); } final cachedId = await _cacheRepository.getCachedAssetId(item.id); if (cachedId != null) { - return ResolutionResult( - localAssetId: cachedId, - status: ResolutionStatus.resolved, - ); + if (await _verifyAssetLoadable(cachedId)) { + return ResolutionResult( + localAssetId: cachedId, + status: ResolutionStatus.resolved, + ); + } + _log.info('Cached library match for media ${item.id} is gone'); + await _cacheRepository.clearEntry(item.id); } final cacheEntry = await _cacheRepository.getCacheEntry(item.id); if (cacheEntry != null && diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart index 3a4a2db7cf..f57b188a70 100644 --- a/test/features/media/data/services/asset_resolution_permission_test.dart +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -157,6 +157,41 @@ void main() { expect(r.localAssetId, 'B-1'); }); + // A mapping this search cached can go stale (a second re-index). It runs + // only after a read has failed, so it proves the mapping before trusting + // it: a stale one would otherwise be served as nothing, forever, and read + // as notFound on the linking device. + test( + 'findInLibrary searches again past a cached mapping that is gone', + () async { + addPhoto(); + await cache.cacheResolution( + mediaId: 'f1', + localAssetId: 'B-gone', + method: 'filename_timestamp', + ); + + final r = await service.findInLibrary(fileRow()); + + expect(r.localAssetId, 'B-1'); + expect((await cache.getCacheEntry('f1'))!.localAssetId, 'B-1'); + }, + ); + + test('findInLibrary keeps a cached mapping that still loads', () async { + addPhoto(); + await cache.cacheResolution( + mediaId: 'f1', + localAssetId: 'B-1', + method: 'filename_timestamp', + ); + library.queryError = StateError('a search would fail'); + + final r = await service.findInLibrary(fileRow()); + + expect(r.localAssetId, 'B-1', reason: 'no search was needed'); + }); + test('findInLibrary under limited access is inconclusive', () async { addPhoto(); library From 14f9abeae0cfb88a825f04f33a947ee28e4aceab Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 23:26:35 -0400 Subject: [PATCH 10/12] fix(media): a backed-off gallery miss is evidence only under full access resolveAssetId and findInLibrary honoured an unexpired unresolved entry before reading permission, so a miss cached under limited access (which every older build wrote) or before the user narrowed access still read as notFound on the linking device. The backoff now reads permission without prompting: it stands under full access, and is inconclusive otherwise, flagged limited under a limited selection. --- .../services/asset_resolution_service.dart | 37 ++++++++++++-- .../asset_resolution_permission_test.dart | 48 +++++++++++++++++++ .../asset_resolution_service_test.dart | 4 ++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index 01c6847a48..99193c7547 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -124,9 +124,7 @@ class AssetResolutionService { final cacheEntry = await _cacheRepository.getCacheEntry(item.id); if (cacheEntry != null && cacheEntry.localAssetId == null) { final expired = await _cacheRepository.isExpired(item.id); - if (!expired) { - return const ResolutionResult(status: ResolutionStatus.unavailable); - } + if (!expired) return _backedOff(item); } // Deduplicate concurrent resolution requests for the same media @@ -229,7 +227,7 @@ class AssetResolutionService { if (cacheEntry != null && cacheEntry.localAssetId == null && !await _cacheRepository.isExpired(item.id)) { - return const ResolutionResult(status: ResolutionStatus.unavailable); + return _backedOff(item); } final pending = _pendingResolutions[item.id]; if (pending != null) return pending; @@ -242,6 +240,37 @@ class AssetResolutionService { } } + /// The answer for a row whose earlier search gave up and is backing off. + /// That miss is evidence the photo is gone only if the search saw the + /// whole library, and it may not have: every build before this one cached + /// a miss under limited access, and the user may have narrowed access + /// since (spec 6.3). So it stands only under full access, read without + /// prompting; anything less is inconclusive. Only backed-off rows pay for + /// the read. + Future _backedOff(MediaItem item) async { + final PhotoPermissionStatus permission; + try { + permission = await _photoPickerService.currentPermission(); + } on Object catch (e, stackTrace) { + _log.error( + 'Permission check failed for backed-off media ${item.id}', + error: e, + stackTrace: stackTrace, + ); + return const ResolutionResult(status: ResolutionStatus.accessDenied); + } + return switch (permission) { + PhotoPermissionStatus.authorized => const ResolutionResult( + status: ResolutionStatus.unavailable, + ), + PhotoPermissionStatus.limited => const ResolutionResult( + status: ResolutionStatus.accessDenied, + limitedAccess: true, + ), + _ => const ResolutionResult(status: ResolutionStatus.accessDenied), + }; + } + /// The permission gate and the metadata tiers: everything a search does /// once the stored id has failed or there is none. Future _searchGallery(MediaItem item) async { diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart index f57b188a70..ea5245a633 100644 --- a/test/features/media/data/services/asset_resolution_permission_test.dart +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -204,6 +204,54 @@ void main() { expect(r.limitedAccess, isTrue); }); + /// A search that gave up on the row earlier and is still backing off. + Future backedOff(String mediaId) => cache.cacheResolution( + mediaId: mediaId, + localAssetId: null, + method: 'unresolved', + ); + + // A miss cached before limited access was honoured (every older build + // cached one under limited access), or before the user narrowed access, + // may not have seen the photo: it is evidence only under full access. + test('a backed-off row under limited access is inconclusive', () async { + await backedOff('m1'); + library.permission = PhotoPermissionStatus.limited; + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isTrue); + }); + + test('a backed-off row under full access is still unavailable', () async { + await backedOff('m1'); + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.unavailable); + }); + + test('a backed-off row with access denied is inconclusive', () async { + await backedOff('m1'); + library.permission = PhotoPermissionStatus.denied; + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isFalse); + }); + + test('a backed-off file row under limited access is inconclusive', () async { + await backedOff('f1'); + library.permission = PhotoPermissionStatus.limited; + + final r = await service.findInLibrary(fileRow()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isTrue); + }); + test('a permission read that fails is inconclusive', () async { final failing = _FailingPermission(); final r = await AssetResolutionService( diff --git a/test/features/media/data/services/asset_resolution_service_test.dart b/test/features/media/data/services/asset_resolution_service_test.dart index d5c3c62db6..fc95714721 100644 --- a/test/features/media/data/services/asset_resolution_service_test.dart +++ b/test/features/media/data/services/asset_resolution_service_test.dart @@ -147,6 +147,10 @@ void main() { ), ); when(mockCache.isExpired('media-1')).thenAnswer((_) async => false); + // A backoff is evidence of absence only under full access. + when( + mockPicker.currentPermission(), + ).thenAnswer((_) async => PhotoPermissionStatus.authorized); final result = await service.resolveAssetId(createTestItem()); From dce5930b95e8a6452459682f8055572f59ece547 Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Wed, 23 Sep 2026 23:37:19 -0400 Subject: [PATCH 11/12] fix(media): shared gallery queries follow the photo access they were taken under Gallery queries are shared for 30 seconds, keyed only by time window, so a limited search could reuse one taken under full access and match a photo that is now hidden, and a photo just added through Choose photo again stayed invisible until the entry expired. The key now carries the permission, and coming back from either access action drops the shared queries, since a changed selection keeps the same permission. --- .../services/asset_resolution_service.dart | 15 +++++++- .../widgets/limited_access_actions.dart | 7 +++- .../asset_resolution_permission_test.dart | 38 +++++++++++++++++++ .../widgets/limited_access_actions_test.dart | 33 +++++++++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index 99193c7547..dec1d1d266 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -240,6 +240,11 @@ class AssetResolutionService { } } + /// Drops the shared gallery queries, so the next search sees the library + /// as it is now. Called when the user comes back from changing photo + /// access, since a changed limited selection keeps the same permission. + void forgetGalleryQueries() => _galleryQueryCache.clear(); + /// The answer for a row whose earlier search gave up and is backing off. /// That miss is evidence the photo is gone only if the search saw the /// whole library, and it may not have: every build before this one cached @@ -326,6 +331,7 @@ class AssetResolutionService { final found = await _getAssetsCoalesced( reading.subtract(timeWindow), reading.add(timeWindow), + permission, ); for (final asset in found) { byId[asset.id] = asset; @@ -591,13 +597,20 @@ class AssetResolutionService { /// When opening a dive with many photos, all providers fire near-simultaneously /// with overlapping time windows. This method caches the gallery query results /// for 30 seconds so only one actual gallery scan is performed per time window. + /// + /// Keyed by [permission] too: a query taken under full access shows photos + /// a limited selection hides, and answering a limited search with it would + /// match a photo this device can no longer read (spec 6.3). A change of + /// selection under the same permission goes through [forgetGalleryQueries]. Future> _getAssetsCoalesced( DateTime start, DateTime end, + PhotoPermissionStatus permission, ) async { final (bucketStart, bucketEnd) = galleryQueryBucket(start, end); final cacheKey = - '${bucketStart.millisecondsSinceEpoch}~${bucketEnd.millisecondsSinceEpoch}'; + '${permission.name}:${bucketStart.millisecondsSinceEpoch}~' + '${bucketEnd.millisecondsSinceEpoch}'; // Check for a valid cached result final cached = _galleryQueryCache[cacheKey]; diff --git a/lib/features/media/presentation/widgets/limited_access_actions.dart b/lib/features/media/presentation/widgets/limited_access_actions.dart index ef7a3d0b6b..95767d35d2 100644 --- a/lib/features/media/presentation/widgets/limited_access_actions.dart +++ b/lib/features/media/presentation/widgets/limited_access_actions.dart @@ -4,6 +4,7 @@ import 'package:submersion/core/models/log_entry.dart'; import 'package:submersion/core/providers/provider.dart'; import 'package:submersion/core/services/logger_service.dart'; import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; import 'package:submersion/l10n/l10n_extension.dart'; /// "Allow full access" and "Choose photo again", for a photo outside the @@ -45,7 +46,11 @@ class _LimitedAccessActionsState extends ConsumerState { } void _changed() { - if (mounted) widget.onChanged(); + if (!mounted) return; + // The shared gallery queries show the library as it was; a selection + // changed under the same permission would stay invisible behind them. + ref.read(assetResolutionServiceProvider).forgetGalleryQueries(); + widget.onChanged(); } Future _openSettings() async { diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart index ea5245a633..c130463807 100644 --- a/test/features/media/data/services/asset_resolution_permission_test.dart +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -252,6 +252,44 @@ void main() { expect(r.limitedAccess, isTrue); }); + // Gallery queries are shared for 30 seconds. One taken under full access + // must not answer a search made after the user narrowed access: it would + // match a photo that is now hidden. + test('a query from full access is not reused under limited access', () async { + addPhoto(); + await service.resolveAssetId(row()); + await cache.clearEntry('m1'); + library + ..permission = PhotoPermissionStatus.limited + ..hiddenFromLimitedAccess.add('B-1'); + + final r = await service.resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isTrue); + }); + + // "Choose photo again" changes the selection without changing the + // permission: the shared queries are dropped so the added photo shows. + test( + 'a photo added to the selection is found once queries are forgotten', + () async { + addPhoto(); + library + ..permission = PhotoPermissionStatus.limited + ..hiddenFromLimitedAccess.add('B-1'); + expect( + (await service.resolveAssetId(row())).status, + ResolutionStatus.accessDenied, + ); + + library.hiddenFromLimitedAccess.remove('B-1'); + service.forgetGalleryQueries(); + + expect((await service.resolveAssetId(row())).localAssetId, 'B-1'); + }, + ); + test('a permission read that fails is inconclusive', () async { final failing = _FailingPermission(); final r = await AssetResolutionService( diff --git a/test/features/media/presentation/widgets/limited_access_actions_test.dart b/test/features/media/presentation/widgets/limited_access_actions_test.dart index b2f6ddd528..ac72ee507a 100644 --- a/test/features/media/presentation/widgets/limited_access_actions_test.dart +++ b/test/features/media/presentation/widgets/limited_access_actions_test.dart @@ -1,11 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/features/media/data/repositories/local_asset_cache_repository.dart'; +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; import 'package:submersion/features/media/data/services/photo_access_actions.dart'; import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; import 'package:submersion/features/media/presentation/widgets/limited_access_actions.dart'; import 'package:submersion/l10n/arb/app_localizations.dart'; +import '../../../../helpers/fake_photo_picker_service.dart'; + class _RecordingActions implements PhotoAccessActions { final calls = []; Object? error; @@ -23,22 +28,42 @@ class _RecordingActions implements PhotoAccessActions { } } +/// Counts how often the shared gallery queries were dropped. +class _CountingResolution extends AssetResolutionService { + _CountingResolution() + : super( + cacheRepository: LocalAssetCacheRepository(), + photoPickerService: FakePhotoPickerService(), + ); + + int forgets = 0; + + @override + void forgetGalleryQueries() => forgets++; +} + /// A photo outside the user's limited selection offers the two ways back: /// full access in the system settings, or adding it to the selection /// (media sync program spec 6.3). void main() { late _RecordingActions actions; + late _CountingResolution resolution; late int changes; setUp(() { actions = _RecordingActions(); + resolution = _CountingResolution(); changes = 0; }); Future pump(WidgetTester tester) => tester.pumpWidget( ProviderScope( - overrides: [photoAccessActionsProvider.overrideWithValue(actions)], + overrides: [ + photoAccessActionsProvider.overrideWithValue(actions), + assetResolutionServiceProvider.overrideWithValue(resolution), + ], child: MaterialApp( + locale: const Locale('en'), localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Scaffold(body: LimitedAccessActions(onChanged: () => changes++)), @@ -63,6 +88,7 @@ void main() { await tester.pump(); expect(changes, 1); + expect(resolution.forgets, 1); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); @@ -90,6 +116,11 @@ void main() { expect(actions.calls, ['choose']); expect(changes, 1); + expect( + resolution.forgets, + 1, + reason: 'the selection changed under the same permission', + ); }); // A platform that cannot open the sheet (an older OS) must not surface From 73bc5ce641f89bccf4025caa800b4dbf2bf4f40a Mon Sep 17 00:00:00 2001 From: Eric Griffin Date: Thu, 24 Sep 2026 00:08:41 -0400 Subject: [PATCH 12/12] test(media): cover slice 9's fallback branches The limited-access provider's no-library and failed-read fallbacks, the desktop currentPermission, findInLibrary with no photo library, a backoff whose permission read throws, a library search that throws on a failed content-URI read, verify re-finding a photo under a new id, and the info panel re-reading access after an action. --- .../local_file_resolver_content_uri_test.dart | 18 ++++++ .../platform_gallery_resolver_stale_test.dart | 18 ++++++ .../asset_resolution_permission_test.dart | 31 +++++++++++ .../photo_picker_service_desktop_test.dart | 8 +++ .../photo_access_providers_test.dart | 55 +++++++++++++++++++ .../widgets/media_info_panel_test.dart | 42 ++++++++++++++ 6 files changed, 172 insertions(+) create mode 100644 test/features/media/presentation/providers/photo_access_providers_test.dart diff --git a/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart index 8bddea80d1..af915d8421 100644 --- a/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart +++ b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart @@ -94,6 +94,24 @@ void main() { expect((data as UnavailableData).kind, UnavailableKind.accessDenied); }); + // A search that throws said nothing about the file: the read's own + // verdict stands, and the render does not fail. + test( + 'a library search that throws falls back to the read\'s verdict', + () async { + final data = await LocalFileResolver( + bookmarkStorage: _NullBookmarkStorage(), + platform: _FailingUriPlatform('PERMISSION_DENIED'), + exifExtractor: ExifExtractor(), + readsContentUris: () => true, + localDeviceId: () async => 'me', + findInLibrary: (item) async => throw StateError('channel'), + ).resolve(row()); + + expect((data as UnavailableData).kind, UnavailableKind.accessDenied); + }, + ); + // Another device's content URI never had a grant here, so it is not a // lost grant and not worth a library search per render. test('another device\'s content URI is not searched', () async { diff --git a/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart index 9640eb3cdf..f48c68893f 100644 --- a/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart +++ b/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart @@ -113,6 +113,24 @@ void main() { expect((data as UnavailableData).kind, UnavailableKind.notFound); }); + test('verify finds a cached photo re-found under a new id', () async { + library.add( + FakeGalleryAsset( + id: 'B-2', + bytes: Uint8List.fromList([5]), + takenAt: DateTime(2024), + ), + ); + final service = _StaleCacheService( + const ResolutionResult( + localAssetId: 'B-2', + status: ResolutionStatus.resolved, + ), + ); + + expect(await resolver(service).verify(row()), VerifyResult.available); + }); + test('verify re-searches a cached photo that no longer exists', () async { final service = _StaleCacheService(limited); diff --git a/test/features/media/data/services/asset_resolution_permission_test.dart b/test/features/media/data/services/asset_resolution_permission_test.dart index c130463807..d42ed03694 100644 --- a/test/features/media/data/services/asset_resolution_permission_test.dart +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -290,6 +290,37 @@ void main() { }, ); + test( + 'findInLibrary on a host with no photo library is unavailable', + () async { + final none = AssetResolutionService( + cacheRepository: cache, + photoPickerService: FakePhotoPickerService( + supportsGalleryBrowsing: false, + ), + ); + + final r = await none.findInLibrary(fileRow()); + + expect(r.status, ResolutionStatus.unavailable); + }, + ); + + test( + 'a backed-off row whose permission read fails is inconclusive', + () async { + await backedOff('m1'); + + final r = await AssetResolutionService( + cacheRepository: cache, + photoPickerService: _FailingPermission(), + ).resolveAssetId(row()); + + expect(r.status, ResolutionStatus.accessDenied); + expect(r.limitedAccess, isFalse); + }, + ); + test('a permission read that fails is inconclusive', () async { final failing = _FailingPermission(); final r = await AssetResolutionService( diff --git a/test/features/media/data/services/photo_picker_service_desktop_test.dart b/test/features/media/data/services/photo_picker_service_desktop_test.dart index aefa7dcfaf..e34a62ce94 100644 --- a/test/features/media/data/services/photo_picker_service_desktop_test.dart +++ b/test/features/media/data/services/photo_picker_service_desktop_test.dart @@ -24,6 +24,14 @@ void main() { expect(metadata, isNull); }); + // Desktop reads files through a dialog, with nothing to prompt for. + test('currentPermission is authorized without asking', () async { + expect( + await PhotoPickerServiceDesktop().currentPermission(), + PhotoPermissionStatus.authorized, + ); + }); + group('assetInfoForFile', () { late Directory tempDir; late PhotoPickerServiceDesktop service; diff --git a/test/features/media/presentation/providers/photo_access_providers_test.dart b/test/features/media/presentation/providers/photo_access_providers_test.dart new file mode 100644 index 0000000000..0abf9b2aa8 --- /dev/null +++ b/test/features/media/presentation/providers/photo_access_providers_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:submersion/core/providers/provider.dart'; +import 'package:submersion/features/media/data/services/photo_picker_service.dart'; +import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/features/media/presentation/providers/photo_picker_providers.dart'; + +import '../../../../helpers/fake_photo_picker_service.dart'; + +class _FailingRead extends FakePhotoPickerService { + @override + Future currentPermission() async => + throw StateError('channel'); +} + +/// The info panel offers the limited-access actions only when this device's +/// access is a limited selection (media sync program spec 6.3). The answer +/// is an offer, never a verdict, so anything uncertain reads as false. +void main() { + Future limitedWith(PhotoPickerService photos) async { + final container = ProviderContainer( + overrides: [photoPickerServiceProvider.overrideWithValue(photos)], + ); + addTearDown(container.dispose); + return container.read(galleryAccessLimitedProvider.future); + } + + test('a limited selection reads as limited', () async { + expect( + await limitedWith( + FakePhotoPickerService(permission: PhotoPermissionStatus.limited), + ), + isTrue, + ); + }); + + test('full access does not', () async { + expect(await limitedWith(FakePhotoPickerService()), isFalse); + }); + + test('a host with no photo library does not', () async { + expect( + await limitedWith( + FakePhotoPickerService( + permission: PhotoPermissionStatus.limited, + supportsGalleryBrowsing: false, + ), + ), + isFalse, + ); + }); + + test('a permission read that fails does not', () async { + expect(await limitedWith(_FailingRead()), isFalse); + }); +} diff --git a/test/features/media/presentation/widgets/media_info_panel_test.dart b/test/features/media/presentation/widgets/media_info_panel_test.dart index 8a6d95c57b..c1bdd1d684 100644 --- a/test/features/media/presentation/widgets/media_info_panel_test.dart +++ b/test/features/media/presentation/widgets/media_info_panel_test.dart @@ -2,11 +2,15 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:submersion/features/media/data/repositories/local_asset_cache_repository.dart'; +import 'package:submersion/features/media/data/services/asset_resolution_service.dart'; import 'package:submersion/features/media/data/services/media_health_report.dart'; import 'package:submersion/features/media/data/services/media_health_reporter.dart'; import 'package:submersion/features/media/data/services/media_item_verifier.dart'; +import 'package:submersion/features/media/data/services/photo_access_actions.dart'; import 'package:submersion/features/media/domain/value_objects/verify_result.dart'; import 'package:submersion/features/media/presentation/providers/photo_access_providers.dart'; +import 'package:submersion/features/media/presentation/providers/resolved_asset_providers.dart'; import 'package:submersion/features/media_store/data/media_transfer_queue_repository.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -31,6 +35,7 @@ import 'package:submersion/features/media_store/presentation/providers/media_sto import 'package:submersion/features/settings/presentation/providers/settings_providers.dart'; import 'package:submersion/features/settings/presentation/providers/sync_providers.dart'; +import '../../../../helpers/fake_photo_picker_service.dart'; import '../../../../helpers/l10n_test_helpers.dart'; import '../../../../helpers/mock_providers.dart'; @@ -251,6 +256,35 @@ void main() { expect(find.text('Choose photo again'), findsOneWidget); }); + // Coming back from the selection sheet re-reads the access state, so + // the buttons go once the user has granted what the photo needs. + testWidgets('an action refreshes the access state', (tester) async { + var reads = 0; + await pump( + tester, + _item(), + extra: [ + galleryAccessLimitedProvider.overrideWith((ref) async { + reads++; + return true; + }), + photoAccessActionsProvider.overrideWithValue(_NoopAccessActions()), + assetResolutionServiceProvider.overrideWithValue( + AssetResolutionService( + cacheRepository: LocalAssetCacheRepository(), + photoPickerService: FakePhotoPickerService(), + ), + ), + ], + ); + expect(reads, 1); + + await tester.tap(find.text('Choose photo again')); + await tester.pumpAndSettle(); + + expect(reads, 2); + }); + testWidgets('full access offers neither', (tester) async { await pump( tester, @@ -913,3 +947,11 @@ void main() { }); }); } + +class _NoopAccessActions implements PhotoAccessActions { + @override + Future openSettings() async {} + + @override + Future chooseMorePhotos() async {} +}