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..853317e45b --- /dev/null +++ b/docs/superpowers/plans/2026-09-23-media-sync-phase2-android-limited.md @@ -0,0 +1,178 @@ +# 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`. + +## 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. + +- 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/docs/superpowers/specs/2026-09-18-media-sync-program-design.md b/docs/superpowers/specs/2026-09-18-media-sync-program-design.md index 4c61ed4489..5993cab430 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 @@ -477,6 +477,18 @@ hides rows the device did link. - Reproduction plan: ask the #1625 reporter for a single-row health report; reproduce on the maintainer's Android phone with limited access, with a moved file, and across an OS re-index. +- Decided 2026-09-23 while planning: the actions appear in the full-screen + viewer and the media info panel, not on grid tiles, which show a distinct + "Not in your allowed photos" placeholder; "Choose photo again" opens the + system's limited-selection sheet (`PhotoManager.presentLimited`) and the + row keeps its link; a lost content-URI grant on the linking device searches + the library by the metadata tiers (`AssetResolutionService.findInLibrary`, + which needs no stored asset id) and is `accessDenied` if nothing matches; + resolution reads permission through a new, non-prompting + `PhotoPickerService.currentPermission`, so the OS prompt comes only from + the picker and "Allow full access". A gallery query that throws is + `accessDenied` too, since `unavailable` read as `notFound` on the linking + device. The PR refs #1625 rather than closing it (section 10). ### 6.4 #425 diff --git a/lib/features/media/data/resolvers/local_file_resolver.dart b/lib/features/media/data/resolvers/local_file_resolver.dart index 0e7c09ac86..c247cb824f 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()) { @@ -356,6 +372,33 @@ class LocalFileResolver implements MediaSourceResolver, DiagnosticProbe { return const UnavailableData(kind: UnavailableKind.notFound); } + /// A content URI that did not read, on this device (spec 6.3). Another + /// device's URI never had a grant here, so it is left to [resolve]'s + /// origin rule. Otherwise the library is searched by metadata before + /// anything is decided: a re-indexed or moved photo is usually still + /// there. A lost grant the search cannot recover is inconclusive, since + /// the file may be exactly where it was. + Future _afterFailedUriRead( + MediaItem item, { + required bool grantLost, + }) async { + if (await _importedElsewhere(item)) { + return const UnavailableData(kind: UnavailableKind.notFound); + } + final search = _findInLibrary; + if (search != null) { + try { + final found = await search(item); + if (found != null) return found; + } on Object catch (e) { + _log.warning('Library search for item ${item.id} failed', error: e); + } + } + return UnavailableData( + kind: grantLost ? UnavailableKind.accessDenied : UnavailableKind.notFound, + ); + } + /// [_volumeOnline], with a probe that itself failed treated as online. /// /// The probe is a filesystem call and can throw on the exact mounts it @@ -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/resolvers/platform_gallery_resolver.dart b/lib/features/media/data/resolvers/platform_gallery_resolver.dart index 8713e57bfc..c3cb30bc3e 100644 --- a/lib/features/media/data/resolvers/platform_gallery_resolver.dart +++ b/lib/features/media/data/resolvers/platform_gallery_resolver.dart @@ -154,13 +154,36 @@ class PlatformGalleryResolver implements MediaSourceResolver { // and collapsing the two would report "your photo is gone" for what is // really "let me look at your photos". if (resolution.status == ResolutionStatus.accessDenied) { - return const UnavailableData(kind: UnavailableKind.accessDenied); + return UnavailableData( + kind: UnavailableKind.accessDenied, + limitedAccess: resolution.limitedAccess, + ); } 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 @@ -191,9 +214,12 @@ class PlatformGalleryResolver implements MediaSourceResolver { // Load-bearing: grid tiles call resolveThumbnail, so without this every // tile on a permission-revoked device reports notFound and the // reconciler would orphan the whole library. - final status = (await _resolutionService.resolveAssetId(item)).status; - if (status == ResolutionStatus.accessDenied) { - return const UnavailableData(kind: UnavailableKind.accessDenied); + final again = await _resolutionService.resolveAssetId(item); + if (again.status == ResolutionStatus.accessDenied) { + return UnavailableData( + kind: UnavailableKind.accessDenied, + limitedAccess: again.limitedAccess, + ); } return _missing(item); } @@ -252,6 +278,19 @@ class PlatformGalleryResolver implements MediaSourceResolver { if (resolvedId != null && await _reader.exists(resolvedId)) { return VerifyResult.available; } + // A cached mapping whose asset no longer exists is searched again, as + // in resolve: an inconclusive search must not become the orphaning + // verdict (spec 6.3). + if (resolvedId != null) { + final again = await _resolutionService.reresolve(item); + if (again.status == ResolutionStatus.accessDenied) { + return VerifyResult.accessDenied; + } + final newId = again.localAssetId; + if (newId != null && newId != resolvedId && await _reader.exists(newId)) { + return VerifyResult.available; + } + } return await _linkedHere(item) ? VerifyResult.notFound : VerifyResult.fromOtherDevice; diff --git a/lib/features/media/data/services/asset_resolution_service.dart b/lib/features/media/data/services/asset_resolution_service.dart index d6b6c43f7e..dec1d1d266 100644 --- a/lib/features/media/data/services/asset_resolution_service.dart +++ b/lib/features/media/data/services/asset_resolution_service.dart @@ -34,7 +34,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. @@ -115,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 @@ -187,6 +194,91 @@ 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. + /// + /// 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) { + 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 && + cacheEntry.localAssetId == null && + !await _cacheRepository.isExpired(item.id)) { + return _backedOff(item); + } + 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); + } + } + + /// 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 + /// 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 { // 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, @@ -199,15 +291,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 && @@ -230,18 +331,36 @@ class AssetResolutionService { final found = await _getAssetsCoalesced( reading.subtract(timeWindow), reading.add(timeWindow), + permission, ); 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(); + // 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); } @@ -322,6 +441,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); @@ -477,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/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/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/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/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/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/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/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), 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/lib/features/media/presentation/providers/media_resolver_providers.dart b/lib/features/media/presentation/providers/media_resolver_providers.dart index c89c79cf1c..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'; @@ -28,6 +29,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 +126,12 @@ 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 => 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/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..95767d35d2 --- /dev/null +++ b/lib/features/media/presentation/widgets/limited_access_actions.dart @@ -0,0 +1,109 @@ +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/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 +/// user's limited selection (media sync program spec 6.3). Each hands off +/// to the system, and [onChanged] fires once the user is back, so the +/// caller can look for the photo again. +class LimitedAccessActions extends ConsumerStatefulWidget { + const LimitedAccessActions({super.key, required this.onChanged}); + + /// 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 + 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) 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 { + _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: _openSettings, + child: Text(l10n.media_limitedAccess_allowFullAccess), + ), + TextButton( + onPressed: _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 d40d6a449e..52b6aa3bd7 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 @@ -428,6 +435,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/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/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..af915d8421 --- /dev/null +++ b/test/features/media/data/resolvers/local_file_resolver_content_uri_test.dart @@ -0,0 +1,135 @@ +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); + }); + + // 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); + }); + + // 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 { + 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/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_stale_test.dart b/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart new file mode 100644 index 0000000000..f48c68893f --- /dev/null +++ b/test/features/media/data/resolvers/platform_gallery_resolver_stale_test.dart @@ -0,0 +1,140 @@ +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 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); + + expect(await resolver(service).verify(row()), VerifyResult.accessDenied); + expect(service.reresolves, 1); + }); +} 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..ce88232f32 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; @@ -231,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 new file mode 100644 index 0000000000..d42ed03694 --- /dev/null +++ b/test/features/media/data/services/asset_resolution_permission_test.dart @@ -0,0 +1,341 @@ +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); + }, + ); + + // 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); + }); + + /// 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'); + }); + + // 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 + ..permission = PhotoPermissionStatus.limited + ..hiddenFromLimitedAccess.add('B-1'); + + final r = await service.findInLibrary(fileRow()); + + expect(r.status, ResolutionStatus.accessDenied); + 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); + }); + + // 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( + '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( + 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..fc95714721 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( @@ -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()); @@ -174,7 +178,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 +223,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 +297,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 +314,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 +347,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/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/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/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/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/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/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/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..ac72ee507a --- /dev/null +++ b/test/features/media/presentation/widgets/limited_access_actions_test.dart @@ -0,0 +1,140 @@ +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; + + @override + Future openSettings() async { + calls.add('settings'); + if (error != null) throw error!; + } + + @override + Future chooseMorePhotos() async { + calls.add('choose'); + if (error != null) throw error!; + } +} + +/// 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), + assetResolutionServiceProvider.overrideWithValue(resolution), + ], + child: MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: LimitedAccessActions(onChanged: () => changes++)), + ), + ), + ); + + // 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); + expect(resolution.forgets, 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); + }); + + 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); + 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 + // 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..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,10 +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'; @@ -30,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'; @@ -234,6 +240,78 @@ 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); + }); + + // 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, + _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()); @@ -869,3 +947,11 @@ void main() { }); }); } + +class _NoopAccessActions implements PhotoAccessActions { + @override + Future openSettings() async {} + + @override + Future chooseMorePhotos() async {} +} 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); + }); } diff --git a/test/features/media/two_device/resolution_scenarios_test.dart b/test/features/media/two_device/resolution_scenarios_test.dart index 49dd8d226c..8c7af5d4be 100644 --- a/test/features/media/two_device/resolution_scenarios_test.dart +++ b/test/features/media/two_device/resolution_scenarios_test.dart @@ -287,29 +287,25 @@ void main() { ); }); - 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); + 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); + // 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)', - ); + 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); + }); } 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 7694491a68..d7668e4943 100644 --- a/test/helpers/fake_photo_picker_service.dart +++ b/test/helpers/fake_photo_picker_service.dart @@ -68,6 +68,13 @@ class FakePhotoPickerService @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); @@ -96,6 +103,8 @@ class FakePhotoPickerService 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 && @@ -115,10 +124,19 @@ class FakePhotoPickerService _visible(assetId)?.bytes; @override - Future checkPermission() async => permission; + Future checkPermission() async { + prompts++; + return permission; + } @override - Future requestPermission() async => permission; + Future currentPermission() async => permission; + + @override + Future requestPermission() async { + prompts++; + return permission; + } @override Future getFilePath(String assetId) async => null;