Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2021-10-21 10:22:43 +09:00
commit 6c5ac871b8
163 changed files with 4171 additions and 1430 deletions

30
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,30 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: type:bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots or screen recordings to help explain your problem. If they are too private for this public space, feel free to [send them by email](mailto:gallery.aves@gmail.com).
**System information and logs:**
In the app, there are instructions in the `About` page > `Bug Report` section. After following them, paste here your system information and attach your logs.
**Additional context**
Add any other context about the problem here.

View file

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: type:feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '2.5.1' flutter-version: '2.5.3'
- name: Clone the repository. - name: Clone the repository.
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1 - uses: subosito/flutter-action@v1
with: with:
channel: stable channel: stable
flutter-version: '2.5.1' flutter-version: '2.5.3'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1): # Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441 # https://issuetracker.google.com/issues/144111441
@ -50,8 +50,9 @@ jobs:
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
rm release.keystore.asc rm release.keystore.asc
flutter build apk --bundle-sksl-path shaders_2.5.1.sksl.json flutter build appbundle --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json
flutter build appbundle --bundle-sksl-path shaders_2.5.1.sksl.json flutter build apk --flavor universal --bundle-sksl-path shaders_2.5.3.sksl.json
flutter build apk --flavor byAbi --split-per-abi --bundle-sksl-path shaders_2.5.3.sksl.json
rm $AVES_STORE_FILE rm $AVES_STORE_FILE
env: env:
AVES_STORE_FILE: ${{ github.workspace }}/key.jks AVES_STORE_FILE: ${{ github.workspace }}/key.jks
@ -63,14 +64,14 @@ jobs:
- name: Create a release with the APK and App Bundle. - name: Create a release with the APK and App Bundle.
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
with: with:
artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/*.aab" artifacts: "build/app/outputs/bundle/universalRelease/*.aab,build/app/outputs/apk/universal/release/*.apk,build/app/outputs/apk/byAbi/release/*.apk"
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Upload app bundle - name: Upload app bundle
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: appbundle name: appbundle
path: build/app/outputs/bundle/release/app-release.aab path: build/app/outputs/bundle/universalRelease/app-universal-release.aab
release: release:
name: Create beta release on Play Store. name: Create beta release on Play Store.
@ -89,7 +90,7 @@ jobs:
with: with:
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }} serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
packageName: deckers.thibault.aves packageName: deckers.thibault.aves
releaseFiles: app-release.aab releaseFiles: app-universal-release.aab
track: beta track: beta
status: completed status: completed
whatsNewDirectory: whatsnew whatsNewDirectory: whatsnew

View file

@ -1,50 +1,88 @@
# Changelog # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
## [v1.5.3] - 2021-09-30
### Added ### Added
- Collection: use a foreground service when scanning many items
- Collection: ask to rename/replace/skip when moving items with name conflict
- Map: filter to view items from a specific region in the Collection page
- Viewer: option to show/hide overlay on opening
- Info: improved display for PNG text metadata, XMP and others
- Export: output format selection
- Search: added raw filter
- Support modifying files in the Download folder on Android 11+
### Changed
- upgraded Flutter to stable v2.5.3
- use build flavors to generate universal or split APKs
### Fixed
- hide root album of hidden path
- gesture & spacing handling for Android 10+ navigation gestures
- renaming was leaving behind obsolete items in some cases
- speeding up videos on Xiaomi devices
## [v1.5.3] - 2021-09-30
### Added
- Map: show items for bounds, open items in viewer, tap gesture to toggle fullscreen - Map: show items for bounds, open items in viewer, tap gesture to toggle fullscreen
- Info: remove metadata (Exif, XMP, etc.) - Info: remove metadata (Exif, XMP, etc.)
- Accessibility: support "time to take action" and "remove animations" settings - Accessibility: support "time to take action" and "remove animations" settings
### Changed ### Changed
- upgraded Flutter to stable v2.5.1 - upgraded Flutter to stable v2.5.1
- faster collection loading when launching the app - faster collection loading when launching the app
- Collection: changed color & scale of thumbnail icons to match text - Collection: changed color & scale of thumbnail icons to match text
- Albums / Countries / Tags: changed layout, with label below cover - Albums / Countries / Tags: changed layout, with label below cover
### Fixed ### Fixed
- album bookmarks & pins were reset when rescanning items - album bookmarks & pins were reset when rescanning items
## [v1.5.2] - 2021-09-29 [YANKED] ## [v1.5.2] - 2021-09-29 [YANKED]
## [v1.5.1] - 2021-09-08 ## [v1.5.1] - 2021-09-08
### Added ### Added
- About: bug reporting instructions - About: bug reporting instructions
### Changed ### Changed
- Collection: improved video date detection - Collection: improved video date detection
### Fixed ### Fixed
- fixed hanging app when loading thumbnails for some video formats on some devices - fixed hanging app when loading thumbnails for some video formats on some devices
## [v1.5.0] - 2021-09-02 ## [v1.5.0] - 2021-09-02
### Added ### Added
- Info: edit Exif dates (setting, shifting, deleting) - Info: edit Exif dates (setting, shifting, deleting)
- Collection: custom quick actions for item selection - Collection: custom quick actions for item selection
- Collection: video date detection for more formats - Collection: video date detection for more formats
### Changed ### Changed
- faster collection loading when launching the app - faster collection loading when launching the app
### Fixed ### Fixed
- app launching on some devices - app launching on some devices
- corrupting motion photo exif editing (e.g. rotation) - corrupting motion photo exif editing (e.g. rotation)
## [v1.4.9] - 2021-08-20 ## [v1.4.9] - 2021-08-20
### Added ### Added
- Map & Stats from selection - Map & Stats from selection
- Map: item browsing, rotation control - Map: item browsing, rotation control
- Navigation menu customization - Navigation menu customization
@ -52,19 +90,24 @@ All notable changes to this project will be documented in this file.
- support Android 12/S (API 31) - support Android 12/S (API 31)
## [v1.4.8] - 2021-08-08 ## [v1.4.8] - 2021-08-08
### Added ### Added
- Map - Map
- Viewer: action to copy to clipboard - Viewer: action to copy to clipboard
- integration with Android global search (Samsung Finder etc.) - integration with Android global search (Samsung Finder etc.)
### Fixed ### Fixed
- auto album identification and naming - auto album identification and naming
- opening HEIC images from downloads content URI on Android R+ - opening HEIC images from downloads content URI on Android R+
## [v1.4.7] - 2021-08-06 [YANKED] ## [v1.4.7] - 2021-08-06 [YANKED]
## [v1.4.6] - 2021-07-22 ## [v1.4.6] - 2021-07-22
### Added ### Added
- Albums / Countries / Tags: multiple selection - Albums / Countries / Tags: multiple selection
- Albums: action to create empty albums - Albums: action to create empty albums
- Collection: burst shot grouping (Samsung naming pattern) - Collection: burst shot grouping (Samsung naming pattern)
@ -74,18 +117,23 @@ All notable changes to this project will be documented in this file.
- Settings: option to exclude cutout area in viewer - Settings: option to exclude cutout area in viewer
### Changed ### Changed
- Video: restored overlay hiding when pressing play button - Video: restored overlay hiding when pressing play button
### Fixed ### Fixed
- Viewer: fixed manual screen rotation to follow sensor - Viewer: fixed manual screen rotation to follow sensor
## [v1.4.5] - 2021-07-08 ## [v1.4.5] - 2021-07-08
### Added ### Added
- Video: added OGV/Theora/Vorbis support - Video: added OGV/Theora/Vorbis support
- Viewer: action to rotate screen when device has locked rotation - Viewer: action to rotate screen when device has locked rotation
- Settings: import/export - Settings: import/export
### Changed ### Changed
- improved SVG support with a different rendering engine - improved SVG support with a different rendering engine
- changed logo - changed logo
- upgraded Flutter to stable v2.2.3 - upgraded Flutter to stable v2.2.3
@ -93,76 +141,97 @@ All notable changes to this project will be documented in this file.
- viewer: parallax effect when scrolling - viewer: parallax effect when scrolling
### Removed ### Removed
- Analytics: removed Firebase Analytics (kept Firebase Crashlytics) - Analytics: removed Firebase Analytics (kept Firebase Crashlytics)
## [v1.4.4] - 2021-06-25 ## [v1.4.4] - 2021-06-25
### Added ### Added
- Video: speed control, track selection, frame capture - Video: speed control, track selection, frame capture
- Video: embedded subtitle support - Video: embedded subtitle support
- Settings: custom video quick actions - Settings: custom video quick actions
- Settings: subtitle theme - Settings: subtitle theme
### Changed ### Changed
- upgraded Flutter to stable v2.2.2 - upgraded Flutter to stable v2.2.2
### Fixed ### Fixed
- fixed opening SVGs from other apps - fixed opening SVGs from other apps
- stop video playback when leaving the app in some cases - stop video playback when leaving the app in some cases
- fixed crash when ACCESS_MEDIA_LOCATION permission is revoked - fixed crash when ACCESS_MEDIA_LOCATION permission is revoked
## [v1.4.3] - 2021-06-12 ## [v1.4.3] - 2021-06-12
### Added ### Added
- Collection: snack bar action to show moved/copied/exported entries - Collection: snack bar action to show moved/copied/exported entries
- Collection / Albums / Countries / Tags: when switching device orientation, keep items in view - Collection / Albums / Countries / Tags: when switching device orientation, keep items in view
- Collection: when leaving entry from Viewer, make entry visible in collection - Collection: when leaving entry from Viewer, make entry visible in collection
- Viewer: fixed layout & minimap for videos with non-square pixels - Viewer: fixed layout & minimap for videos with non-square pixels
### Changed ### Changed
- upgraded Flutter to stable v2.2.1 - upgraded Flutter to stable v2.2.1
- migrated to unsound null safety - migrated to unsound null safety
- Collection / Viewer: improved performance, memory usage - Collection / Viewer: improved performance, memory usage
- Collection: thumbnail layout change - Collection: thumbnail layout change
### Removed ### Removed
- no support for Android KitKat (API 19), unsupported by Google Maps package - no support for Android KitKat (API 19), unsupported by Google Maps package
### Fixed ### Fixed
- fixed opening files shared via content URI with incorrect MIME type - fixed opening files shared via content URI with incorrect MIME type
- refresh collection when entries modified in Viewer no longer match collection filters - refresh collection when entries modified in Viewer no longer match collection filters
## [v1.4.2] - 2021-06-10 [YANKED] ## [v1.4.2] - 2021-06-10 [YANKED]
## [v1.4.1] - 2021-04-29 ## [v1.4.1] - 2021-04-29
### Added ### Added
- Motion photo support - Motion photo support
- Viewer: play videos in multi-track HEIC - Viewer: play videos in multi-track HEIC
- Handle share intent - Handle share intent
### Changed ### Changed
- Upgraded Flutter to beta v2.2.0-10.1.pre - Upgraded Flutter to beta v2.2.0-10.1.pre
### Fixed ### Fixed
- fixed crash when cataloguing large MP4/PSD - fixed crash when cataloguing large MP4/PSD
- prevent videos playing in the background when quickly switching entries - prevent videos playing in the background when quickly switching entries
## [v1.4.0] - 2021-04-16 ## [v1.4.0] - 2021-04-16
### Added ### Added
- Viewer: support for videos with EAC3/FLAC/OPUS audio - Viewer: support for videos with EAC3/FLAC/OPUS audio
- Info: more consistent and comprehensive info for videos and streams - Info: more consistent and comprehensive info for videos and streams
- Settings: more video options (auto play, loop, hardware acceleration) - Settings: more video options (auto play, loop, hardware acceleration)
### Changed ### Changed
- Info: present video cover like XMP embedded images - Info: present video cover like XMP embedded images
### Removed ### Removed
- locale name package (-3 MB) - locale name package (-3 MB)
### Fixed ### Fixed
- Albums: auto naming for folders on SD card - Albums: auto naming for folders on SD card
- Viewer: display of videos with unusual SAR - Viewer: display of videos with unusual SAR
## [v1.3.7] - 2021-04-02 ## [v1.3.7] - 2021-04-02
### Added ### Added
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb - Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
- Albums: localized common album names - Albums: localized common album names
- Collection: select shortcut icon image - Collection: select shortcut icon image
@ -170,165 +239,209 @@ All notable changes to this project will be documented in this file.
- Settings: option to hide videos from collection - Settings: option to hide videos from collection
### Changed ### Changed
- Upgraded Flutter to beta v2.1.0-12.2.pre - Upgraded Flutter to beta v2.1.0-12.2.pre
### Fixed ### Fixed
- opening media shared by other apps as file media content - opening media shared by other apps as file media content
- navigation stack when opening media shared by other apps - navigation stack when opening media shared by other apps
## [v1.3.6] - 2021-03-18 ## [v1.3.6] - 2021-03-18
### Added ### Added
- Korean translation - Korean translation
- cover selection for albums / countries / tags - cover selection for albums / countries / tags
### Changed ### Changed
- Upgraded Flutter to dev v2.1.0-12.1.pre - Upgraded Flutter to dev v2.1.0-12.1.pre
### Fixed ### Fixed
- various TIFF decoding fixes - various TIFF decoding fixes
## [v1.3.5] - 2021-02-26 ## [v1.3.5] - 2021-02-26
### Added ### Added
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23) - support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23)
- quick country reverse geocoding without Play Services - quick country reverse geocoding without Play Services
- menu option to hide any filter - menu option to hide any filter
- menu option to navigate to the album / country / tag page from filter - menu option to navigate to the album / country / tag page from filter
### Changed ### Changed
- analytics are opt-in - analytics are opt-in
### Removed ### Removed
- removed custom font used in titles and info page - removed custom font used in titles and info page
## [v1.3.4] - 2021-02-10 ## [v1.3.4] - 2021-02-10
### Added ### Added
- hide album / country / tag from collection - hide album / country / tag from collection
- new version check - new version check
### Changed ### Changed
- Viewer: improved multipage item overlay and thumbnail loading - Viewer: improved multipage item overlay and thumbnail loading
- deactivate geocoding and Google maps when Play Services are unavailable - deactivate geocoding and Google maps when Play Services are unavailable
### Fixed ### Fixed
- refreshing items externally added/moved/removed - refreshing items externally added/moved/removed
- loading items at the root of volumes - loading items at the root of volumes
- loading items when opening a shortcut with a location filter - loading items when opening a shortcut with a location filter
- various thumbnail hero animation fixes - various thumbnail hero animation fixes
## [v1.3.3] - 2021-01-31 ## [v1.3.3] - 2021-01-31
### Added ### Added
- Viewer: support for multi-track HEIF - Viewer: support for multi-track HEIF
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP) - Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
- Info: show owner app (Android Q and up) - Info: show owner app (Android Q and up)
- listen to Media Store changes - listen to Media Store changes
### Changed ### Changed
- upgraded Flutter to stable v1.22.6 - upgraded Flutter to stable v1.22.6
- check connectivity before using features that need it - check connectivity before using features that need it
### Fixed ### Fixed
- checkerboard background performance - checkerboard background performance
- deleting files that no longer exist but are still registered in the Media Store - deleting files that no longer exist but are still registered in the Media Store
- insets handling on Android 11 - insets handling on Android 11
## [v1.3.2] - 2021-01-17 ## [v1.3.2] - 2021-01-17
### Added ### Added
Collection: identify multipage TIFF & multitrack HEIC/HEIF
Viewer: support for multipage TIFF Collection: identify multipage TIFF & multitrack HEIC/HEIF Viewer: support for multipage TIFF
Viewer: support for cropped panoramas Viewer: support for cropped panoramas Albums: grouping options
Albums: grouping options
### Changed ### Changed
upgraded libtiff to 4.2.0 for TIFF decoding upgraded libtiff to 4.2.0 for TIFF decoding
### Fixed ### Fixed
- prevent scrolling when using Android Q style gesture navigation - prevent scrolling when using Android Q style gesture navigation
## [v1.3.1] - 2021-01-04 ## [v1.3.1] - 2021-01-04
### Added ### Added
- Collection: long press and move to select/deselect multiple items - Collection: long press and move to select/deselect multiple items
- Info: show Spherical Video V1 metadata - Info: show Spherical Video V1 metadata
- Info: metadata search - Info: metadata search
### Fixed ### Fixed
- Viewer: fixed panning inertia following double-tap scaling - Viewer: fixed panning inertia following double-tap scaling
- Collection: fixed crash when loading TIFF files on Android 11 - Collection: fixed crash when loading TIFF files on Android 11
## [v1.3.0] - 2020-12-26 ## [v1.3.0] - 2020-12-26
### Added ### Added
- Viewer: quick scale (aka one finger zoom) - Viewer: quick scale (aka one finger zoom)
- Viewer: optional checkered background for transparent images - Viewer: optional checkered background for transparent images
### Changed ### Changed
- Viewer: changed panning inertia - Viewer: changed panning inertia
### Fixed ### Fixed
- Viewer: fixed scaling focus when zooming by double-tap or pinch - Viewer: fixed scaling focus when zooming by double-tap or pinch
- Viewer: fixed panning during scaling - Viewer: fixed panning during scaling
## [v1.2.9] - 2020-12-12 ## [v1.2.9] - 2020-12-12
### Added ### Added
- Collection: identify 360 photos/videos, GeoTIFF - Collection: identify 360 photos/videos, GeoTIFF
- Viewer: open panoramas (360 photos) - Viewer: open panoramas (360 photos)
- Info: open GImage/GAudio/GDepth media and thumbnails embedded in XMP - Info: open GImage/GAudio/GDepth media and thumbnails embedded in XMP
- Info: SVG metadata - Info: SVG metadata
### Changed ### Changed
- Upgraded Flutter to stable v1.22.5 - Upgraded Flutter to stable v1.22.5
- Viewer: TIFF subsampling & tiling - Viewer: TIFF subsampling & tiling
- Info: improved XMP layout - Info: improved XMP layout
### Fixed ### Fixed
- Fixed large TIFF handling - Fixed large TIFF handling
## [v1.2.8] - 2020-11-27 ## [v1.2.8] - 2020-11-27
### Added ### Added
- Albums / Countries / Tags: pinch to change tile size - Albums / Countries / Tags: pinch to change tile size
- Album picker: added a field to filter by name - Album picker: added a field to filter by name
- check free space before moving items - check free space before moving items
- SVG source viewer - SVG source viewer
### Changed ### Changed
- Navigation: changed page history handling - Navigation: changed page history handling
- Info: improved layout, especially for XMP - Info: improved layout, especially for XMP
- About: improved layout - About: improved layout
- faster locating of new items - faster locating of new items
## [v1.2.7] - 2020-11-15 ## [v1.2.7] - 2020-11-15
### Added ### Added
- Support for TIFF images (single page) - Support for TIFF images (single page)
- Viewer overlay: minimap (optional) - Viewer overlay: minimap (optional)
### Changed ### Changed
- Upgraded Flutter to stable v1.22.4 - Upgraded Flutter to stable v1.22.4
- Viewer: use subsampling and tiling to display large images - Viewer: use subsampling and tiling to display large images
### Fixed ### Fixed
- Fixed finding dimensions of items with incorrect EXIF - Fixed finding dimensions of items with incorrect EXIF
## [v1.2.6] - 2020-11-15 [YANKED] ## [v1.2.6] - 2020-11-15 [YANKED]
## [v1.2.5] - 2020-11-01 ## [v1.2.5] - 2020-11-01
### Added ### Added
- Search: show recently used filters (optional) - Search: show recently used filters (optional)
- Search: show filter for items with no XMP tags - Search: show filter for items with no XMP tags
- Search: show filter for items with no location information - Search: show filter for items with no location information
- Analytics: use Firebase Analytics (along Firebase Crashlytics) - Analytics: use Firebase Analytics (along Firebase Crashlytics)
### Changed ### Changed
- Upgraded Flutter to stable v1.22.3 - Upgraded Flutter to stable v1.22.3
- Viewer overlay: showing shooting details is now optional - Viewer overlay: showing shooting details is now optional
### Fixed ### Fixed
- Viewer: leave when the loaded item is deleted and it is the last one - Viewer: leave when the loaded item is deleted and it is the last one
- Viewer: refresh the viewer overlay and info page when the loaded image is modified - Viewer: refresh the viewer overlay and info page when the loaded image is modified
- Info: prevent reporting a "Media" section for images other than HEIC/HEIF - Info: prevent reporting a "Media" section for images other than HEIC/HEIF
- Fixed opening items shared via a "file" media content URI - Fixed opening items shared via a "file" media content URI
### Removed ### Removed
- Dependencies: removed Guava as a direct dependency in Android - Dependencies: removed Guava as a direct dependency in Android
## [v1.2.4] - 2020-11-01 [YANKED] ## [v1.2.4] - 2020-11-01 [YANKED]
## [v1.2.3] - 2020-10-22 ## [v1.2.3] - 2020-10-22
... ...

View file

@ -35,9 +35,28 @@ Aves requires a few permissions to do its job:
- **have network access**: necessary for the map view, and most likely for precise reverse geocoding too, - **have network access**: necessary for the map view, and most likely for precise reverse geocoding too,
- **view network connections**: checking for connection states allows Aves to gracefully degrade features that depend on internet. - **view network connections**: checking for connection states allows Aves to gracefully degrade features that depend on internet.
## Contributing
### Issues
[Bug reports](https://github.com/deckerst/aves/issues/new?assignees=&labels=type%3Abug&template=bug_report.md&title=) and [feature requests](https://github.com/deckerst/aves/issues/new?assignees=&labels=type%3Afeature&template=feature_request.md&title=) are welcome. Questions too, though you could also ask them in [Discussions](https://github.com/deckerst/aves/discussions).
### Code
At this stage this project does *not* accept PRs, except for translations.
### Translations
If you want to translate this app in your language and share the result, feel free to open a PR or send the translation by [email](mailto:gallery.aves@gmail.com). You can find some localization notes in [pubspec.yaml](https://github.com/deckerst/aves/blob/develop/pubspec.yaml). English, Korean and French (soon™) are already handled.
### Donations
Some users have expressed the wish to financially support the project. I haven't set up any sponsorship system, but you can send contributions [here](https://paypal.me/ThibaultDeckers). Thanks! ❤️
## Project Setup ## Project Setup
Create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See `<app dir>/android/key_template.properties` for the expected keys. To build the project, create a file named `<app dir>/android/key.properties`. It should contain a reference to a keystore for app signing, and other necessary credentials. See [key_template.properties](https://github.com/deckerst/aves/blob/develop/android/key_template.properties) for the expected keys.
You can run the app with `flutter run --flavor universal`.
[Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver [Version badge]: https://img.shields.io/github/v/release/deckerst/aves?include_prereleases&sort=semver
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check [Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check

View file

@ -77,6 +77,21 @@ android {
} }
} }
// the "splitting" dimension and its flavors are only for building purposes:
// NDK ABI filters are not compatible with split APK generation
// but we want to generate both a universal APK without x86 libs, and split APKs
flavorDimensions "splitting"
productFlavors {
universal {
dimension "splitting"
}
byAbi {
dimension "splitting"
}
}
buildTypes { buildTypes {
debug { debug {
applicationIdSuffix ".debug" applicationIdSuffix ".debug"
@ -86,6 +101,15 @@ android {
applicationIdSuffix ".profile" applicationIdSuffix ".profile"
resValue 'string', 'search_provider', "${appId}.profile.search_provider" resValue 'string', 'search_provider', "${appId}.profile.search_provider"
} }
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
def runTasks = gradle.startParameter.taskNames.toString().toLowerCase()
if (runTasks.contains("universal")) {
release { release {
// specify architectures, to specifically exclude native libs for x86, // specify architectures, to specifically exclude native libs for x86,
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so" // which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
@ -93,12 +117,7 @@ android {
ndk { ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
} }
}
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
} }
} }
} }
@ -120,10 +139,10 @@ dependencies {
implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.commonsware.cwac:document:0.4.1'
implementation 'com.drewnoakes:metadata-extractor:2.16.0' implementation 'com.drewnoakes:metadata-extractor:2.16.0'
// https://jitpack.io/p/deckerst/Android-TiffBitmapFactory // forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
// https://jitpack.io/p/deckerst/pixymeta-android // forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' // forked, built by JitPack implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
implementation 'com.github.bumptech.glide:glide:4.12.0' implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.2.0' kapt 'androidx.annotation:annotation:1.2.0'

View file

@ -16,6 +16,7 @@
https://developer.android.com/preview/privacy/storage#media-files-raw-paths https://developer.android.com/preview/privacy/storage#media-files-raw-paths
--> -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<!-- request write permission until Q (29) included, because scoped storage is unusable --> <!-- request write permission until Q (29) included, because scoped storage is unusable -->
<uses-permission <uses-permission
@ -122,6 +123,10 @@
android:name="android.app.searchable" android:name="android.app.searchable"
android:resource="@xml/searchable" /> android:resource="@xml/searchable" />
</activity> </activity>
<service
android:name=".AnalysisService"
android:description="@string/analysis_service_description"
android:exported="false" />
<!-- file provider to share files having a file:// URI --> <!-- file provider to share files having a file:// URI -->
<provider <provider

View file

@ -0,0 +1,244 @@
package deckers.thibault.aves
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.*
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.MainActivity.Companion.OPEN_FROM_ANALYSIS_SERVICE
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.GeocodingHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.runBlocking
import java.util.*
class AnalysisService : MethodChannel.MethodCallHandler, Service() {
private var backgroundFlutterEngine: FlutterEngine? = null
private var backgroundChannel: MethodChannel? = null
private var serviceLooper: Looper? = null
private var serviceHandler: ServiceHandler? = null
private val analysisServiceBinder = AnalysisServiceBinder()
override fun onCreate() {
Log.i(LOG_TAG, "Create analysis service")
val context = this
runBlocking {
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
}
}
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
// channels for analysis
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// channel for service management
backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler(context)
}
HandlerThread("Analysis service handler", Process.THREAD_PRIORITY_BACKGROUND).apply {
start()
serviceLooper = looper
serviceHandler = ServiceHandler(looper)
}
}
override fun onDestroy() {
Log.i(LOG_TAG, "Destroy analysis service")
}
override fun onBind(intent: Intent) = analysisServiceBinder
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val channel = NotificationChannelCompat.Builder(CHANNEL_ANALYSIS, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getText(R.string.analysis_channel_name))
.setShowBadge(false)
.build()
NotificationManagerCompat.from(this).createNotificationChannel(channel)
startForeground(NOTIFICATION_ID, buildNotification())
val msgData = Bundle()
intent.extras?.let {
msgData.putAll(it)
}
serviceHandler?.obtainMessage()?.let { msg ->
msg.arg1 = startId
msg.data = msgData
serviceHandler?.sendMessage(msg)
}
return START_NOT_STICKY
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initialized" -> {
Log.d(LOG_TAG, "background channel is ready")
result.success(null)
}
"updateNotification" -> {
val title = call.argument<String>("title")
val message = call.argument<String>("message")
val notification = buildNotification(title, message)
NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, notification)
result.success(null)
}
"refreshApp" -> {
analysisServiceBinder.refreshApp()
result.success(null)
}
"stop" -> {
detachAndStop()
result.success(null)
}
else -> result.notImplemented()
}
}
private fun detachAndStop() {
analysisServiceBinder.detach()
stopSelf()
}
private fun buildNotification(title: String? = null, message: String? = null): Notification {
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val stopServiceIntent = Intent(this, AnalysisService::class.java).let {
it.putExtra(KEY_COMMAND, COMMAND_STOP)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags)
} else {
PendingIntent.getService(this, STOP_SERVICE_REQUEST, it, pendingIntentFlags)
}
}
val openAppIntent = Intent(this, MainActivity::class.java).let {
PendingIntent.getActivity(this, OPEN_FROM_ANALYSIS_SERVICE, it, pendingIntentFlags)
}
val stopAction = NotificationCompat.Action.Builder(
R.drawable.ic_outline_stop_24,
getString(R.string.analysis_notification_action_stop),
stopServiceIntent
).build()
return NotificationCompat.Builder(this, CHANNEL_ANALYSIS)
.setContentTitle(title ?: getText(R.string.analysis_notification_default_title))
.setContentText(message)
.setBadgeIconType(NotificationCompat.BADGE_ICON_NONE)
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(openAppIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(stopAction)
.build()
}
private inner class ServiceHandler(looper: Looper) : Handler(looper) {
override fun handleMessage(msg: Message) {
val context = this@AnalysisService
val data = msg.data
when (data.getString(KEY_COMMAND)) {
COMMAND_START -> {
runBlocking {
context.runOnUiThread {
val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
backgroundChannel?.invokeMethod(
"start", hashMapOf(
"contentIds" to contentIds,
"force" to data.getBoolean(KEY_FORCE),
)
)
}
}
}
COMMAND_STOP -> {
// unconditionally stop the service
runBlocking {
context.runOnUiThread {
backgroundChannel?.invokeMethod("stop", null)
}
}
detachAndStop()
}
else -> {
}
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisService>()
private const val BACKGROUND_CHANNEL = "deckers.thibault/aves/analysis_service_background"
const val SHARED_PREFERENCES_KEY = "analysis_service"
const val CALLBACK_HANDLE_KEY = "callback_handle"
const val NOTIFICATION_ID = 1
const val STOP_SERVICE_REQUEST = 1
const val CHANNEL_ANALYSIS = "analysis"
const val KEY_COMMAND = "command"
const val COMMAND_START = "start"
const val COMMAND_STOP = "stop"
const val KEY_CONTENT_IDS = "content_ids"
const val KEY_FORCE = "force"
}
}
class AnalysisServiceBinder : Binder() {
private val listeners = hashSetOf<AnalysisServiceListener>()
fun startListening(listener: AnalysisServiceListener) = listeners.add(listener)
fun stopListening(listener: AnalysisServiceListener) = listeners.remove(listener)
fun refreshApp() {
val localListeners = listeners.toSet()
for (listener in localListeners) {
try {
listener.refreshApp()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to notify listener=$listener", e)
}
}
}
fun detach() {
val localListeners = listeners.toSet()
for (listener in localListeners) {
try {
listener.detachFromActivity()
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to detach listener=$listener", e)
}
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisServiceBinder>()
}
}
interface AnalysisServiceListener {
fun refreshApp()
fun detachFromActivity()
}

View file

@ -15,19 +15,21 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler private lateinit var intentStreamHandler: IntentStreamHandler
private lateinit var analysisStreamHandler: AnalysisStreamHandler
private lateinit var intentDataMap: MutableMap<String, Any?> private lateinit var intentDataMap: MutableMap<String, Any?>
private lateinit var analysisHandler: AnalysisHandler
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
Log.i(LOG_TAG, "onCreate intent=$intent") Log.i(LOG_TAG, "onCreate intent=$intent")
@ -52,24 +54,30 @@ class MainActivity : FlutterActivity() {
val messenger = flutterEngine!!.dartExecutor.binaryMessenger val messenger = flutterEngine!!.dartExecutor.binaryMessenger
// dart -> platform -> dart // dart -> platform -> dart
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) // - need Context
analysisHandler = AnalysisHandler(this, ::onAnalysisCompleted)
MethodChannel(messenger, AnalysisHandler.CHANNEL).setMethodCallHandler(analysisHandler)
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this)) MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this)) MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler()) MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this)) MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this)) MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need Activity
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this)) MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
// result streaming: dart -> platform ->->-> dart // result streaming: dart -> platform ->->-> dart
// - need Context
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) } StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
// - need Activity
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) } StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
// change monitoring: platform -> dart // change monitoring: platform -> dart
@ -97,6 +105,11 @@ class MainActivity : FlutterActivity() {
} }
} }
// notification: platform -> dart
analysisStreamHandler = AnalysisStreamHandler().apply {
EventChannel(messenger, AnalysisStreamHandler.CHANNEL).setStreamHandler(this)
}
// notification: platform -> dart // notification: platform -> dart
errorStreamHandler = ErrorStreamHandler().apply { errorStreamHandler = ErrorStreamHandler().apply {
EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this) EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this)
@ -107,7 +120,20 @@ class MainActivity : FlutterActivity() {
} }
} }
override fun onStart() {
Log.i(LOG_TAG, "onStart")
super.onStart()
analysisHandler.attachToActivity()
}
override fun onStop() {
Log.i(LOG_TAG, "onStop")
analysisHandler.detachFromActivity()
super.onStop()
}
override fun onDestroy() { override fun onDestroy() {
Log.i(LOG_TAG, "onDestroy")
mediaStoreChangeStreamHandler.dispose() mediaStoreChangeStreamHandler.dispose()
settingsChangeStreamHandler.dispose() settingsChangeStreamHandler.dispose()
super.onDestroy() super.onDestroy()
@ -122,7 +148,8 @@ class MainActivity : FlutterActivity() {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) { when (requestCode) {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode) DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode)
DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode) DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST, CREATE_FILE_REQUEST,
OPEN_FILE_REQUEST, OPEN_FILE_REQUEST,
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data) SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
@ -147,10 +174,9 @@ class MainActivity : FlutterActivity() {
onStorageAccessResult(requestCode, treeUri) onStorageAccessResult(requestCode, treeUri)
} }
private fun onDeletePermissionResult(resultCode: Int) { private fun onScopedStoragePermissionResult(resultCode: Int) {
// delete permission may be requested on Android 10+ only
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) pendingScopedStoragePermissionCompleter?.complete(resultCode == RESULT_OK)
} }
} }
@ -252,19 +278,27 @@ class MainActivity : FlutterActivity() {
ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search)) ShortcutManagerCompat.setDynamicShortcuts(this, listOf(videos, search))
} }
private fun onAnalysisCompleted() {
analysisStreamHandler.notifyCompletion()
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MainActivity>() private val LOG_TAG = LogUtils.createTag<MainActivity>()
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
const val EXTRA_STRING_ARRAY_SEPARATOR = "###" const val EXTRA_STRING_ARRAY_SEPARATOR = "###"
const val DOCUMENT_TREE_ACCESS_REQUEST = 1 const val DOCUMENT_TREE_ACCESS_REQUEST = 1
const val DELETE_PERMISSION_REQUEST = 2 const val OPEN_FROM_ANALYSIS_SERVICE = 2
const val CREATE_FILE_REQUEST = 3 const val CREATE_FILE_REQUEST = 3
const val OPEN_FILE_REQUEST = 4 const val OPEN_FILE_REQUEST = 4
const val SELECT_DIRECTORY_REQUEST = 5 const val SELECT_DIRECTORY_REQUEST = 5
const val DELETE_SINGLE_PERMISSION_REQUEST = 6
const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7
// request code to pending runnable // request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>() val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) { private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri") Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return

View file

@ -2,24 +2,21 @@ package deckers.thibault.aves
import android.app.SearchManager import android.app.SearchManager
import android.content.ContentProvider import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.database.MatrixCursor import android.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler
import android.util.Log import android.util.Log
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.ContextUtils.resourceUri
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.view.FlutterCallbackInformation
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -71,7 +68,9 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> { private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
if (backgroundFlutterEngine == null) { if (backgroundFlutterEngine == null) {
initFlutterEngine(context) FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
backgroundFlutterEngine = it
}
} }
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
@ -86,7 +85,7 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
"locale" to Locale.getDefault().toString(), "locale" to Locale.getDefault().toString(),
), object : MethodChannel.Result { ), object : MethodChannel.Result {
override fun success(result: Any?) { override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") @Suppress("unchecked_cast")
cont.resume(result as List<FieldMap>) cont.resume(result as List<FieldMap>)
} }
@ -133,60 +132,5 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
const val CALLBACK_HANDLE_KEY = "callback_handle" const val CALLBACK_HANDLE_KEY = "callback_handle"
private var backgroundFlutterEngine: FlutterEngine? = null private var backgroundFlutterEngine: FlutterEngine? = null
private suspend fun initFlutterEngine(context: Context) {
val callbackHandle = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE).getLong(CALLBACK_HANDLE_KEY, 0)
if (callbackHandle == 0L) {
Log.e(LOG_TAG, "failed to retrieve registered callback handle")
return
}
lateinit var flutterLoader: FlutterLoader
context.runOnUiThread {
// initialization must happen on the main thread
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
startInitialization(context)
ensureInitializationComplete(context, null)
}
}
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callbackInfo == null) {
Log.e(LOG_TAG, "failed to find callback information")
return
}
val args = DartExecutor.DartCallback(
context.assets,
flutterLoader.findAppBundlePath(),
callbackInfo
)
context.runOnUiThread {
// initialization must happen on the main thread
backgroundFlutterEngine = FlutterEngine(context).apply {
dartExecutor.executeDartCallback(args)
}
}
}
// convenience methods
private suspend fun Context.runOnUiThread(r: Runnable) {
suspendCoroutine<Boolean> { cont ->
Handler(mainLooper).post {
r.run()
cont.resume(true)
}
}
}
private fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(getResourcePackageName(resourceId))
.appendPath(getResourceTypeName(resourceId))
.appendPath(getResourceEntryName(resourceId))
.build()
}
} }
} }

View file

@ -22,7 +22,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
} }
} }
private fun areAnimationsRemoved(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false var removed = false
try { try {
removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
@ -32,7 +32,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
result.success(removed) result.success(removed)
} }
private fun hasRecommendedTimeouts(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
} }

View file

@ -0,0 +1,115 @@
package deckers.thibault.aves.channel.calls
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Build
import android.os.IBinder
import android.util.Log
import deckers.thibault.aves.AnalysisService
import deckers.thibault.aves.AnalysisServiceBinder
import deckers.thibault.aves.AnalysisServiceListener
import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::registerCallback) }
"startService" -> Coresult.safe(call, result, ::startAnalysis)
else -> result.notImplemented()
}
}
@SuppressLint("CommitPrefEdits")
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) {
result.error("registerCallback-args", "failed because of missing arguments", null)
return
}
activity.getSharedPreferences(AnalysisService.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE)
.edit()
.putLong(AnalysisService.CALLBACK_HANDLE_KEY, callbackHandle)
.apply()
result.success(true)
}
private fun startAnalysis(call: MethodCall, result: MethodChannel.Result) {
val force = call.argument<Boolean>("force")
if (force == null) {
result.error("startAnalysis-args", "failed because of missing arguments", null)
return
}
// can be null or empty
val contentIds = call.argument<List<Int>>("contentIds");
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
intent.putExtra(AnalysisService.KEY_CONTENT_IDS, contentIds?.toIntArray())
intent.putExtra(AnalysisService.KEY_FORCE, force)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent)
} else {
activity.startService(intent)
}
}
attachToActivity()
result.success(null)
}
private var attached = false
fun attachToActivity() {
if (activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
activity.bindService(intent, connection, Context.BIND_AUTO_CREATE)
attached = true
}
}
override fun detachFromActivity() {
if (attached) {
attached = false
activity.unbindService(connection)
}
}
override fun refreshApp() {
if (attached) {
onAnalysisCompleted()
}
}
private val connection = object : ServiceConnection {
var binder: AnalysisServiceBinder? = null
override fun onServiceConnected(name: ComponentName, service: IBinder) {
Log.i(LOG_TAG, "Analysis service connected")
binder = service as AnalysisServiceBinder
binder?.startListening(this@AnalysisHandler)
}
override fun onServiceDisconnected(name: ComponentName) {
Log.i(LOG_TAG, "Analysis service disconnected")
binder?.stopListening(this@AnalysisHandler)
binder = null
}
}
companion object {
private val LOG_TAG = LogUtils.createTag<AnalysisHandler>()
const val CHANNEL = "deckers.thibault/aves/analysis"
}
}

View file

@ -53,7 +53,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} }
} }
private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getPackages(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val packages = HashMap<String, FieldMap>() val packages = HashMap<String, FieldMap>()
fun addPackageDetails(intent: Intent) { fun addPackageDetails(intent: Intent) {
@ -76,7 +76,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// The following methods do not work: // The following methods do not work:
// - `resources.getConfiguration().setLocale(...)` // - `resources.getConfiguration().setLocale(...)`
// - getting a package manager from a custom context with `context.createConfigurationContext(config)` // - getting a package manager from a custom context with `context.createConfigurationContext(config)`
@Suppress("DEPRECATION") @Suppress("deprecation")
resources.updateConfiguration(englishConfig, resources.displayMetrics) resources.updateConfiguration(englishConfig, resources.displayMetrics)
englishLabel = resources.getString(labelRes) englishLabel = resources.getString(labelRes)
} catch (e: Exception) { } catch (e: Exception) {
@ -321,7 +321,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(isPinSupported()) result.success(isPinSupported())
} }

View file

@ -60,7 +60,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
} }
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getContextDirs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf( val dirs = hashMapOf(
"cacheDir" to context.cacheDir, "cacheDir" to context.cacheDir,
"filesDir" to context.filesDir, "filesDir" to context.filesDir,
@ -83,7 +83,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
result.success(dirs) result.success(dirs)
} }
private fun getEnv(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getEnv(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(System.getenv()) result.success(System.getenv())
} }

View file

@ -16,15 +16,18 @@ class DeviceHandler : MethodCallHandler {
} }
} }
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id) result.success(TimeZone.getDefault().id)
} }
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS) val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
if (performanceClass > 0) {
result.success(performanceClass)
return return
} }
}
result.success(Build.VERSION.SDK_INT) result.success(Build.VERSION.SDK_INT)
} }

View file

@ -1,11 +1,9 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.util.Log
import deckers.thibault.aves.SearchSuggestionsProvider import deckers.thibault.aves.SearchSuggestionsProvider
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.MethodCallHandler
@ -13,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class GlobalSearchHandler(private val context: Activity) : MethodCallHandler { class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) } "registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) }
@ -21,6 +19,7 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
} }
} }
@SuppressLint("CommitPrefEdits")
private fun registerCallback(call: MethodCall, result: MethodChannel.Result) { private fun registerCallback(call: MethodCall, result: MethodChannel.Result) {
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong() val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
if (callbackHandle == null) { if (callbackHandle == null) {
@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
} }
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<GlobalSearchHandler>()
const val CHANNEL = "deckers.thibault/aves/global_search" const val CHANNEL = "deckers.thibault/aves/global_search"
} }
} }

View file

@ -11,6 +11,7 @@ import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.TiffRegionFetcher
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -34,7 +35,6 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) } "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) } "getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) } "captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) } "clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented() else -> result.notImplemented()
} }
@ -144,7 +144,8 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
val exifFields = call.argument<FieldMap>("exif") ?: HashMap() val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes") val bytes = call.argument<ByteArray>("bytes")
var destinationDir = call.argument<String>("destinationPath") var destinationDir = call.argument<String>("destinationPath")
if (uri == null || desiredName == null || bytes == null || destinationDir == null) { val nameConflictStrategy = NameConflictStrategy.get(call.argument<String>("nameConflictStrategy"))
if (uri == null || desiredName == null || bytes == null || destinationDir == null || nameConflictStrategy == null) {
result.error("captureFrame-args", "failed because of missing arguments", null) result.error("captureFrame-args", "failed because of missing arguments", null)
return return
} }
@ -156,41 +157,13 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
} }
destinationDir = ensureTrailingSeparator(destinationDir) destinationDir = ensureTrailingSeparator(destinationDir)
provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, object : ImageOpCallback { provider.captureFrame(activity, desiredName, exifFields, bytes, destinationDir, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields) override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message) override fun onFailure(throwable: Throwable) = result.error("captureFrame-failure", "failed to capture frame for uri=$uri", throwable.message)
}) })
} }
private suspend fun rename(call: MethodCall, result: MethodChannel.Result) { private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val entryMap = call.argument<FieldMap>("entry")
val newName = call.argument<String>("newName")
if (entryMap == null || newName == null) {
result.error("rename-args", "failed because of missing arguments", null)
return
}
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
result.error("rename-args", "failed because entry fields are missing", null)
return
}
val provider = getProvider(uri)
if (provider == null) {
result.error("rename-provider", "failed to find provider for uri=$uri", null)
return
}
provider.rename(activity, path, uri, mimeType, newName, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = result.success(fields)
override fun onFailure(throwable: Throwable) = result.error("rename-failure", "failed to rename", throwable.message)
})
}
private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
Glide.get(activity).clearDiskCache() Glide.get(activity).clearDiskCache()
result.success(null) result.success(null)
} }

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.app.Activity import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MediaStoreHandler(private val activity: Activity) : MethodCallHandler { class MediaStoreHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) } "checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
@ -28,7 +28,7 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null) result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
return return
} }
result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds)) result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds))
} }
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) { private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
@ -37,13 +37,13 @@ class MediaStoreHandler(private val activity: Activity) : MethodCallHandler {
result.error("checkObsoletePaths-args", "failed because of missing arguments", null) result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
return return
} }
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById)) result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))
} }
private fun scanFile(call: MethodCall, result: MethodChannel.Result) { private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument<String>("path") val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
MediaScannerConnection.scanFile(activity, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) } MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri: Uri? -> result.success(uri?.toString()) }
} }
companion object { companion object {

View file

@ -12,6 +12,7 @@ import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.properties.XMPPropertyInfo import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader import com.drew.imaging.ImageMetadataReader
import com.drew.lang.KeyValuePair
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Tag import com.drew.metadata.Tag
import com.drew.metadata.avi.AviDirectory import com.drew.metadata.avi.AviDirectory
@ -33,16 +34,20 @@ import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeRational
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeDescription
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.Metadata.DIR_PNG_TEXTUAL_DATA
import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode import deckers.thibault.aves.metadata.Metadata.getRotationDegreesForExifCode
import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode import deckers.thibault.aves.metadata.Metadata.isFlippedForExifCode
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_ITXT_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_LAST_MODIFICATION_TIME_FORMAT
import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME import deckers.thibault.aves.metadata.MetadataExtractorHelper.PNG_TIME_DIR_NAME
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff
import deckers.thibault.aves.metadata.MetadataExtractorHelper.isPngTextDir
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeInt import deckers.thibault.aves.metadata.XMP.getSafeInt
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText
@ -52,12 +57,12 @@ import deckers.thibault.aves.metadata.XMP.isPanorama
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeic import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.tiffExtensionPattern
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -66,6 +71,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.nio.charset.StandardCharsets
import java.text.ParseException import java.text.ParseException
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -104,34 +110,45 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java) foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java) foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
val uuidDirCount = HashMap<String, Int>() val uuidDirCount = HashMap<String, Int>()
for (dir in metadata.directories.filter { val dirByName = metadata.directories.filter {
it.tagCount > 0 it.tagCount > 0
&& it !is FileTypeDirectory && it !is FileTypeDirectory
&& it !is AviDirectory && it !is AviDirectory
}) { }.groupBy { dir -> dir.name }
// directory name for (dirEntry in dirByName) {
var dirName = dir.name val baseDirName = dirEntry.key
if (dir is Mp4UuidBoxDirectory) {
val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-')
dirName += " $uuid"
val count = uuidDirCount[uuid] ?: 0
uuidDirCount[uuid] = count + 1
if (count > 0) {
dirName += " ($count)"
}
}
// exclude directories known to be redundant with info derived on the Dart side // exclude directories known to be redundant with info derived on the Dart side
// they are excluded by name instead of runtime type because excluding `Mp4Directory` // they are excluded by name instead of runtime type because excluding `Mp4Directory`
// would also exclude derived directories, such as `Mp4UuidBoxDirectory` // would also exclude derived directories, such as `Mp4UuidBoxDirectory`
if (allMetadataRedundantDirNames.contains(dirName)) continue if (allMetadataRedundantDirNames.contains(baseDirName)) continue
val sameNameDirs = dirEntry.value
val sameNameDirCount = sameNameDirs.size
for (dirIndex in 0 until sameNameDirCount) {
val dir = sameNameDirs[dirIndex]
// directory name
var thisDirName = baseDirName
if (dir is Mp4UuidBoxDirectory) {
val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-')
thisDirName += " $uuid"
val count = uuidDirCount[uuid] ?: 0
uuidDirCount[uuid] = count + 1
if (count > 0) {
thisDirName += " ($count)"
}
} else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
// optional count for multiple directories of the same type
thisDirName = "$thisDirName[${dirIndex + 1}]"
}
// optional parent to distinguish child directories of the same type // optional parent to distinguish child directories of the same type
dir.parent?.name?.let { dirName = "$it/$dirName" } dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
val dirMap = metadataMap[dirName] ?: HashMap() var dirMap = metadataMap[thisDirName] ?: HashMap()
metadataMap[dirName] = dirMap metadataMap[thisDirName] = dirMap
// tags // tags
val tags = dir.tags val tags = dir.tags
@ -155,6 +172,39 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} else { } else {
dirMap.putAll(tags.map { tagMapper(it) }) dirMap.putAll(tags.map { tagMapper(it) })
} }
} else if (dir.isPngTextDir()) {
metadataMap.remove(thisDirName)
dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
for (tag in tags) {
val tagType = tag.tagType
if (tagType == PngDirectory.TAG_TEXTUAL_DATA) {
val pairs = dir.getObject(tagType) as List<*>
val textPairs = pairs.map { pair ->
val kv = pair as KeyValuePair
val key = kv.key
// `PNG-iTXt` uses UTF-8, contrary to `PNG-tEXt` and `PNG-zTXt` using Latin-1 / ISO-8859-1
val charset = if (baseDirName == PNG_ITXT_DIR_NAME) StandardCharsets.UTF_8 else kv.value.charset
val valueString = String(kv.value.bytes, charset)
val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {
dirs.forEach { profileDir ->
val profileDirName = profileDir.name
val profileDirMap = metadataMap[profileDirName] ?: HashMap()
metadataMap[profileDirName] = profileDirMap
profileDirMap.putAll(profileDir.tags.map { Pair(it.tagName, it.description) })
}
null
} else {
Pair(key, valueString)
}
}
dirMap.putAll(textPairs.filterNotNull())
} else {
dirMap[tag.tagName] = tag.description
}
}
} else { } else {
dirMap.putAll(tags.map { Pair(it.tagName, it.description) }) dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
} }
@ -183,20 +233,20 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
GSpherical.SPHERICAL_VIDEO_V1_UUID -> { GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe()) metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
metadataMap.remove(dirName) metadataMap.remove(thisDirName)
} }
QuickTimeMetadata.PROF_UUID -> { QuickTimeMetadata.PROF_UUID -> {
// redundant with info derived on the Dart side // redundant with info derived on the Dart side
metadataMap.remove(dirName) metadataMap.remove(thisDirName)
} }
QuickTimeMetadata.USMT_UUID -> { QuickTimeMetadata.USMT_UUID -> {
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA) val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes) val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
if (blocks.isNotEmpty()) { if (blocks.isNotEmpty()) {
metadataMap.remove(dirName) metadataMap.remove(thisDirName)
dirName = "QuickTime User Media" thisDirName = "QuickTime User Media"
val usmt = metadataMap[dirName] ?: HashMap() val usmt = metadataMap[thisDirName] ?: HashMap()
metadataMap[dirName] = usmt metadataMap[thisDirName] = usmt
blocks.forEach { blocks.forEach {
var key = it.type var key = it.type
@ -218,6 +268,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
} }
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e) Log.w(LOG_TAG, "failed to get metadata by metadata-extractor for uri=$uri", e)
} catch (e: NoClassDefFoundError) { } catch (e: NoClassDefFoundError) {
@ -353,7 +404,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives), // In the end, `metadata-extractor` is the most reliable, except for `tiff` (false positives, false negatives),
// in which case we trust the file extension // in which case we trust the file extension
// cf https://github.com/drewnoakes/metadata-extractor/issues/296 // cf https://github.com/drewnoakes/metadata-extractor/issues/296
if (path?.matches(tiffExtensionPattern) == true) { if (path?.matches(TIFF_EXTENSION_PATTERN) == true) {
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
} else { } else {
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) { dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
@ -658,7 +709,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
try { try {
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input -> Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
val metadata = ImageMetadataReader.readMetadata(input) val metadata = ImageMetadataReader.readMetadata(input)
val fields = hashMapOf<String, Any?>( val fields: FieldMap = hashMapOf(
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT, "projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
) )
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) { for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
@ -767,6 +818,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"QuickTime Sound", "QuickTime Sound",
"QuickTime Video", "QuickTime Video",
) )
private val allMetadataMergeableDirNames = setOf(
"Exif SubIFD",
"GIF Control",
"GIF Image",
"HEIF",
"ICC Profile",
"IPTC",
"WebP",
"XMP",
)
// catalog metadata // catalog metadata
private const val KEY_MIME_TYPE = "mimeType" private const val KEY_MIME_TYPE = "mimeType"

View file

@ -6,6 +6,7 @@ import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import androidx.core.os.EnvironmentCompat import androidx.core.os.EnvironmentCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
@ -28,11 +29,13 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) } "deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getStorageVolumes(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val volumes = ArrayList<Map<String, Any>>() val volumes = ArrayList<Map<String, Any>>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
@ -100,7 +103,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
} }
} }
private fun getGrantedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getGrantedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(ArrayList(PermissionManager.getGrantedDirs(context))) result.success(ArrayList(PermissionManager.getGrantedDirs(context)))
} }
@ -114,7 +117,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths)) result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths))
} }
private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun getRestrictedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(PermissionManager.getRestrictedDirectories(context)) result.success(PermissionManager.getRestrictedDirectories(context))
} }
@ -155,6 +158,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
result.success(deleted) result.success(deleted)
} }
private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
}
private fun canInsertMedia(call: MethodCall, result: MethodChannel.Result) {
val directories = call.argument<List<FieldMap>>("directories")
if (directories == null) {
result.error("canInsertMedia-args", "failed because of missing arguments", null)
return
}
result.success(PermissionManager.canInsertByMediaStore(directories))
}
companion object { companion object {
const val CHANNEL = "deckers.thibault/aves/storage" const val CHANNEL = "deckers.thibault/aves/storage"
} }

View file

@ -40,7 +40,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
result.success(null) result.success(null)
} }
private fun isRotationLocked(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var locked = false var locked = false
try { try {
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0 locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
@ -60,7 +60,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
result.success(true) result.success(true)
} }
private fun canSetCutoutMode(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { private fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
} }

View file

@ -79,7 +79,7 @@ class ThumbnailFetcher internal constructor(
} else { } else {
var errorDetails: String? = exception?.message var errorDetails: String? = exception?.message
if (errorDetails?.isNotEmpty() == true) { if (errorDetails?.isNotEmpty() == true) {
errorDetails = errorDetails.split("\n".toRegex(), 2).first() errorDetails = errorDetails.split(Regex("\n"), 2).first()
} }
result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails) result.error("getThumbnail-null", "failed to get thumbnail for mimeType=$mimeType uri=$uri", errorDetails)
} }
@ -99,10 +99,10 @@ class ThumbnailFetcher internal constructor(
val contentId = uri.tryParseId() ?: return null val contentId = uri.tryParseId() ?: return null
val resolver = context.contentResolver val resolver = context.contentResolver
return if (isVideo(mimeType)) { return if (isVideo(mimeType)) {
@Suppress("DEPRECATION") @Suppress("deprecation")
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null) MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
} else { } else {
@Suppress("DEPRECATION") @Suppress("deprecation")
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null) var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
// from Android Q, returned thumbnail is already rotated according to EXIF orientation // from Android Q, returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {

View file

@ -0,0 +1,25 @@
package deckers.thibault.aves.channel.streams
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
class AnalysisStreamHandler : EventChannel.StreamHandler {
// cannot use `lateinit` because we cannot guarantee
// its initialization in `onListen` at the right time
// e.g. when resuming the app after the activity got destroyed
private var eventSink: EventSink? = null
override fun onListen(arguments: Any?, eventSink: EventSink) {
this.eventSink = eventSink
}
override fun onCancel(arguments: Any?) {}
fun notifyCompletion() {
eventSink?.success(true)
}
companion object {
const val CHANNEL = "deckers.thibault/aves/analysis_events"
}
}

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.app.Activity import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@ -16,8 +16,8 @@ import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter import deckers.thibault.aves.utils.MimeTypes.canDecodeWithFlutter
import deckers.thibault.aves.utils.MimeTypes.isHeic
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
@ -26,10 +26,9 @@ import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.IOException
import java.io.InputStream import java.io.InputStream
class ImageByteStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
private lateinit var eventSink: EventSink private lateinit var eventSink: EventSink
private lateinit var handler: Handler private lateinit var handler: Handler
@ -108,22 +107,22 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
private fun streamImageAsIs(uri: Uri, mimeType: String) { private fun streamImageAsIs(uri: Uri, mimeType: String) {
try { try {
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) } StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) }
} catch (e: IOException) { } catch (e: Exception) {
error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) error("streamImage-image-read-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
} }
} }
private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) { private suspend fun streamImageByGlide(uri: Uri, pageId: Int?, mimeType: String, rotationDegrees: Int, isFlipped: Boolean) {
val model: Any = if (isHeic(mimeType) && pageId != null) { val model: Any = if (isHeic(mimeType) && pageId != null) {
MultiTrackImage(activity, uri, pageId) MultiTrackImage(context, uri, pageId)
} else if (mimeType == MimeTypes.TIFF) { } else if (mimeType == MimeTypes.TIFF) {
TiffImage(activity, uri, pageId) TiffImage(context, uri, pageId)
} else { } else {
StorageUtils.getGlideSafeUri(uri, mimeType) StorageUtils.getGlideSafeUri(uri, mimeType)
} }
val target = Glide.with(activity) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(model) .load(model)
@ -132,7 +131,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get() var bitmap = target.get()
if (needRotationAfterGlide(mimeType)) { if (needRotationAfterGlide(mimeType)) {
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped) bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
} }
if (bitmap != null) { if (bitmap != null) {
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false)) success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
@ -142,15 +141,15 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} catch (e: Exception) { } catch (e: Exception) {
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e)) error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(context).clear(target)
} }
} }
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) { private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) {
val target = Glide.with(activity) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(VideoThumbnail(activity, uri)) .load(VideoThumbnail(context, uri))
.submit() .submit()
try { try {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
@ -163,14 +162,14 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
} catch (e: Exception) { } catch (e: Exception) {
error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message) error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
} finally { } finally {
Glide.with(activity).clear(target) Glide.with(context).clear(target)
} }
} }
private fun toErrorDetails(e: Exception): String? { private fun toErrorDetails(e: Exception): String? {
val errorDetails = e.message val errorDetails = e.message
return if (errorDetails?.isNotEmpty() == true) { return if (errorDetails?.isNotEmpty() == true) {
errorDetails.split("\n".toRegex(), 2).first() errorDetails.split(Regex("\n"), 2).first()
} else { } else {
errorDetails errorDetails
} }

View file

@ -7,6 +7,7 @@ import android.os.Looper
import android.util.Log import android.util.Log
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -28,7 +29,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
op = arguments["op"] as String? op = arguments["op"] as String?
@Suppress("UNCHECKED_CAST") @Suppress("unchecked_cast")
val rawEntries = arguments["entries"] as List<FieldMap>? val rawEntries = arguments["entries"] as List<FieldMap>?
if (rawEntries != null) { if (rawEntries != null) {
entryMapList.addAll(rawEntries) entryMapList.addAll(rawEntries)
@ -44,6 +45,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() } "delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() } "export" -> GlobalScope.launch(Dispatchers.IO) { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() } "move" -> GlobalScope.launch(Dispatchers.IO) { move() }
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename() }
else -> endOfStream() else -> endOfStream()
} }
} }
@ -98,12 +100,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
for (entryMap in entryMapList) { for (entryMap in entryMapList) {
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
if (uri != null) { val mimeType = entryMap["mimeType"] as String?
val result = hashMapOf<String, Any?>( if (uri != null && mimeType != null) {
val result: FieldMap = hashMapOf(
"uri" to uri.toString(), "uri" to uri.toString(),
) )
try { try {
provider.delete(activity, uri, path) provider.delete(activity, uri, path, mimeType)
result["success"] = true result["success"] = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$path", e) Log.w(LOG_TAG, "failed to delete entry with path=$path", e)
@ -123,7 +126,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
var destinationDir = arguments["destinationPath"] as String? var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String? val mimeType = arguments["mimeType"] as String?
if (destinationDir == null || mimeType == null) { val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || nameConflictStrategy == null) {
error("export-args", "failed because of missing arguments", null) error("export-args", "failed because of missing arguments", null)
return return
} }
@ -138,7 +142,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(activity, mimeType, destinationDir, entries, object : ImageOpCallback { provider.exportMultiple(activity, mimeType, destinationDir, entries, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
}) })
@ -153,7 +157,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val copy = arguments["copy"] as Boolean? val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String? var destinationDir = arguments["destinationPath"] as String?
if (copy == null || destinationDir == null) { val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (copy == null || destinationDir == null || nameConflictStrategy == null) {
error("move-args", "failed because of missing arguments", null) error("move-args", "failed because of missing arguments", null)
return return
} }
@ -168,13 +173,41 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(activity, copy, destinationDir, entries, object : ImageOpCallback { provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable) override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
}) })
endOfStream() endOfStream()
} }
private suspend fun rename() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
endOfStream()
return
}
val newName = arguments["newName"] as String?
if (newName == null) {
error("rename-args", "failed because of missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("rename-provider", "failed to find provider for entry=$firstEntry", null)
return
}
val entries = entryMapList.map(::AvesEntry)
provider.renameMultiple(activity, newName, entries, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("rename-failure", "failed to rename", throwable.message)
})
endOfStream()
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>() private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
const val CHANNEL = "deckers.thibault/aves/media_op_stream" const val CHANNEL = "deckers.thibault/aves/media_op_stream"

View file

@ -21,7 +21,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
init { init {
if (arguments is Map<*, *>) { if (arguments is Map<*, *>) {
@Suppress("UNCHECKED_CAST") @Suppress("unchecked_cast")
knownEntries = arguments["knownEntries"] as Map<Int, Int?>? knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
} }
} }

View file

@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.streams
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@ -39,7 +40,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
handler = Handler(Looper.getMainLooper()) handler = Handler(Looper.getMainLooper())
when (op) { when (op) {
"requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() } "requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() }
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
@ -47,19 +49,19 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
} }
} }
private fun requestVolumeAccess() { private fun requestDirectoryAccess() {
val path = args["path"] as String? val path = args["path"] as String?
if (path == null) { if (path == null) {
error("requestVolumeAccess-args", "failed because of missing arguments", null) error("requestDirectoryAccess-args", "failed because of missing arguments", null)
return return
} }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
error("requestVolumeAccess-unsupported", "volume access is not allowed before Android Lollipop", null) error("requestDirectoryAccess-unsupported", "directory access is not allowed before Android Lollipop", null)
return return
} }
PermissionManager.requestVolumeAccess(activity, path, { PermissionManager.requestDirectoryAccess(activity, path, {
success(true) success(true)
endOfStream() endOfStream()
}, { }, {
@ -68,6 +70,28 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}) })
} }
private fun requestMediaFileAccess() {
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null }
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
if (uris == null || uris.isEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
error("requestMediaFileAccess-args", "failed because of missing arguments", null)
return
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android R", null)
return
}
try {
val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes)
success(granted)
} catch (e: Exception) {
error("requestMediaFileAccess-request", "failed to request access to uris=$uris", e.message)
}
endOfStream()
}
private fun createFile() { private fun createFile() {
val name = args["name"] as String? val name = args["name"] as String?
val mimeType = args["mimeType"] as String? val mimeType = args["mimeType"] as String?

View file

@ -2,9 +2,7 @@ package deckers.thibault.aves.metadata
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import java.io.File import java.io.File
@ -15,7 +13,7 @@ import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
object Metadata { object Metadata {
private val LOG_TAG = LogUtils.createTag<Metadata>() const val IPTC_MARKER_BYTE: Byte = 0x1c
// Pattern to extract latitude & longitude from a video location tag (cf ISO 6709) // Pattern to extract latitude & longitude from a video location tag (cf ISO 6709)
// Examples: // Examples:
@ -31,6 +29,7 @@ object Metadata {
const val DIR_XMP = "XMP" // from metadata-extractor const val DIR_XMP = "XMP" // from metadata-extractor
const val DIR_MEDIA = "Media" // custom const val DIR_MEDIA = "Media" // custom
const val DIR_COVER_ART = "Cover" // custom const val DIR_COVER_ART = "Cover" // custom
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata // types of metadata
const val TYPE_EXIF = "exif" const val TYPE_EXIF = "exif"
@ -135,7 +134,6 @@ object Metadata {
} else { } else {
// make a preview from the beginning of the file, // make a preview from the beginning of the file,
// hoping the metadata is accessible in the copied chunk // hoping the metadata is accessible in the copied chunk
Log.d(LOG_TAG, "use a preview for uri=$uri mimeType=$mimeType size=$sizeBytes")
var previewFile = previewFiles[uri] var previewFile = previewFiles[uri]
if (previewFile == null) { if (previewFile == null) {
previewFile = createPreviewFile(context, uri) previewFile = createPreviewFile(context, uri)

View file

@ -1,15 +1,27 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.lang.SequentialByteArrayReader
import com.drew.metadata.Directory import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.iptc.IptcReader
import com.drew.metadata.png.PngDirectory
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
object MetadataExtractorHelper { object MetadataExtractorHelper {
const val PNG_ITXT_DIR_NAME = "PNG-iTXt"
private const val PNG_TEXT_DIR_NAME = "PNG-tEXt"
const val PNG_TIME_DIR_NAME = "PNG-tIME" const val PNG_TIME_DIR_NAME = "PNG-tIME"
private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt"
val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT) val PNG_LAST_MODIFICATION_TIME_FORMAT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.ROOT)
// Pattern to extract profile name, length, and text data
// of raw profiles (EXIF, IPTC, etc.) in PNG `zTXt` chunks
// e.g. "iptc [...] 114 [...] 3842494d040400[...]"
private val PNG_RAW_PROFILE_PATTERN = Regex("^\\n(.*?)\\n\\s*(\\d+)\\n(.*)", RegexOption.DOT_MATCHES_ALL)
// extensions // extensions
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) { fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
@ -59,4 +71,45 @@ object MetadataExtractorHelper {
return true return true
} }
// PNG
fun Directory.isPngTextDir(): Boolean = this is PngDirectory && setOf(PNG_ITXT_DIR_NAME, PNG_TEXT_DIR_NAME, PNG_ZTXT_DIR_NAME).contains(this.name)
fun extractPngProfile(key: String, valueString: String): Iterable<Directory>? {
when (key) {
"Raw profile type iptc" -> {
val match = PNG_RAW_PROFILE_PATTERN.matchEntire(valueString)
if (match != null) {
val dataString = match.groupValues[3]
val hexString = dataString.replace(Regex("[\\r\\n]"), "")
val dataBytes = hexStringToByteArray(hexString)
if (dataBytes != null) {
val start = dataBytes.indexOf(Metadata.IPTC_MARKER_BYTE)
if (start != -1) {
val segmentBytes = dataBytes.copyOfRange(fromIndex = start, toIndex = dataBytes.size - start)
val metadata = com.drew.metadata.Metadata()
IptcReader().extract(SequentialByteArrayReader(segmentBytes), metadata, segmentBytes.size.toLong())
return metadata.directories
}
}
}
}
}
return null
}
// convenience methods
private fun hexStringToByteArray(hexString: String): ByteArray? {
if (hexString.length % 2 != 0) return null
val dataBytes = ByteArray(hexString.length / 2)
var i = 0
while (i < hexString.length) {
dataBytes[i / 2] = hexString.substring(i, i + 2).toByte(16)
i += 2
}
return dataBytes
}
} }

View file

@ -46,7 +46,7 @@ object MultiPage {
val format = extractor.getTrackFormat(i) val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime -> format.getString(MediaFormat.KEY_MIME)?.let { mime ->
val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime val trackMime = if (mime == MediaFormat.MIMETYPE_IMAGE_ANDROID_HEIC) MimeTypes.HEIC else mime
val track = hashMapOf<String, Any?>( val track: FieldMap = hashMapOf(
KEY_PAGE to i, KEY_PAGE to i,
KEY_MIME_TYPE to trackMime, KEY_MIME_TYPE to trackMime,
) )
@ -106,7 +106,7 @@ object MultiPage {
val format = extractor.getTrackFormat(i) val format = extractor.getTrackFormat(i)
format.getString(MediaFormat.KEY_MIME)?.let { mime -> format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) { if (MimeTypes.isVideo(mime)) {
val track = hashMapOf<String, Any?>( val track: FieldMap = hashMapOf(
KEY_PAGE to trackCount++, KEY_PAGE to trackCount++,
KEY_MIME_TYPE to MimeTypes.MP4, KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false, KEY_IS_DEFAULT to false,

View file

@ -0,0 +1,12 @@
package deckers.thibault.aves.model
enum class NameConflictStrategy {
RENAME, REPLACE, SKIP;
companion object {
fun get(name: String?): NameConflictStrategy? {
name ?: return null
return valueOf(name.uppercase())
}
}
}

View file

@ -9,6 +9,7 @@ import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -47,16 +48,16 @@ internal class ContentImageProvider : ImageProvider() {
return return
} }
val map = hashMapOf<String, Any?>( val fields: FieldMap = hashMapOf(
"uri" to uri.toString(), "uri" to uri.toString(),
"sourceMimeType" to mimeType, "sourceMimeType" to mimeType,
) )
try { try {
val cursor = context.contentResolver.query(uri, projection, null, null, null) val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) } cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) } cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = cursor.getString(it) } cursor.getColumnIndex(PATH).let { if (it != -1) fields["path"] = cursor.getString(it) }
cursor.close() cursor.close()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -64,7 +65,7 @@ internal class ContentImageProvider : ImageProvider() {
return return
} }
val entry = SourceEntry(map).fillPreCatalogMetadata(context) val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
if (entry.isSized || entry.isSvg || entry.isVideo) { if (entry.isSized || entry.isSvg || entry.isVideo) {
callback.onSuccess(entry.toMap()) callback.onSuccess(entry.toMap())
} else { } else {
@ -75,7 +76,7 @@ internal class ContentImageProvider : ImageProvider() {
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ContentImageProvider>() private val LOG_TAG = LogUtils.createTag<ContentImageProvider>()
@Suppress("DEPRECATION") @Suppress("deprecation")
const val PATH = MediaStore.MediaColumns.DATA const val PATH = MediaStore.MediaColumns.DATA
private val projection = arrayOf( private val projection = arrayOf(

View file

@ -2,10 +2,14 @@ package deckers.thibault.aves.model.provider
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
@ -21,33 +25,36 @@ import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.utils.* import deckers.thibault.aves.utils.*
import deckers.thibault.aves.utils.MimeTypes.canEditExif import deckers.thibault.aves.utils.MimeTypes.canEditExif
import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import kotlin.collections.HashMap
abstract class ImageProvider { abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider")) callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider"))
} }
open suspend fun delete(activity: Activity, uri: Uri, path: String?) { open suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
throw UnsupportedOperationException("`delete` is not supported by this image provider") throw UnsupportedOperationException("`delete` is not supported by this image provider")
} }
open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, entries: List<AvesEntry>, callback: ImageOpCallback) { open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider"))
} }
open suspend fun renameMultiple(activity: Activity, newFileName: String, entries: List<AvesEntry>, callback: ImageOpCallback) {
callback.onFailure(UnsupportedOperationException("`renameMultiple` is not supported by this image provider"))
}
open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) { open fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider") throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
} }
@ -57,19 +64,26 @@ abstract class ImageProvider {
} }
suspend fun exportMultiple( suspend fun exportMultiple(
context: Context, activity: Activity,
imageExportMimeType: String, imageExportMimeType: String,
destinationDir: String, targetDir: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
if (!supportedExportMimeTypes.contains(imageExportMimeType)) { if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
throw Exception("unsupported export MIME type=$imageExportMimeType") callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType"))
} }
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (destinationDirDocFile == null) { if (!File(targetDir).exists()) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir")) callback.onFailure(Exception("failed to create directory at path=$targetDir"))
return
}
// TODO TLAD [storage] allow inserting by Media Store
if (targetDirDocFile == null) {
callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir"))
return return
} }
@ -78,7 +92,7 @@ abstract class ImageProvider {
val sourcePath = entry.path val sourcePath = entry.path
val pageId = entry.pageId val pageId = entry.pageId
val result = hashMapOf<String, Any?>( val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(), "uri" to sourceUri.toString(),
"pageId" to pageId, "pageId" to pageId,
"success" to false, "success" to false,
@ -88,16 +102,17 @@ abstract class ImageProvider {
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
try { try {
val newFields = exportSingleByTreeDocAndScan( val newFields = exportSingleByTreeDocAndScan(
context = context, activity = activity,
sourceEntry = entry, sourceEntry = entry,
destinationDir = destinationDir, targetDir = targetDir,
destinationDirDocFile = destinationDirDocFile, targetDirDocFile = targetDirDocFile,
nameConflictStrategy = nameConflictStrategy,
exportMimeType = exportMimeType, exportMimeType = exportMimeType,
) )
result["newFields"] = newFields result["newFields"] = newFields
result["success"] = true result["success"] = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e) Log.w(LOG_TAG, "failed to export to targetDir=$targetDir entry with sourcePath=$sourcePath pageId=$pageId", e)
} }
callback.onSuccess(result) callback.onSuccess(result)
} }
@ -105,10 +120,11 @@ abstract class ImageProvider {
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun exportSingleByTreeDocAndScan( private suspend fun exportSingleByTreeDocAndScan(
context: Context, activity: Activity,
sourceEntry: AvesEntry, sourceEntry: AvesEntry,
destinationDir: String, targetDir: String,
destinationDirDocFile: DocumentFileCompat, targetDirDocFile: DocumentFileCompat,
nameConflictStrategy: NameConflictStrategy,
exportMimeType: String, exportMimeType: String,
): FieldMap { ): FieldMap {
val sourceMimeType = sourceEntry.mimeType val sourceMimeType = sourceEntry.mimeType
@ -117,7 +133,7 @@ abstract class ImageProvider {
var desiredNameWithoutExtension = if (sourceEntry.path != null) { var desiredNameWithoutExtension = if (sourceEntry.path != null) {
val sourceFileName = File(sourceEntry.path).name val sourceFileName = File(sourceEntry.path).name
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
} else { } else {
sourceUri.lastPathSegment!! sourceUri.lastPathSegment!!
} }
@ -125,23 +141,29 @@ abstract class ImageProvider {
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}"
} }
val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(exportMimeType)) val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = exportMimeType,
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension) val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
if (isVideo(sourceMimeType)) { if (isVideo(sourceMimeType)) {
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri) val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
sourceDocFile.copyTo(destinationDocFile) sourceDocFile.copyTo(targetDocFile)
} else { } else {
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
MultiTrackImage(context, sourceUri, pageId) MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) { } else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(context, sourceUri, pageId) TiffImage(activity, sourceUri, pageId)
} else { } else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
} }
@ -152,7 +174,7 @@ abstract class ImageProvider {
.diskCacheStrategy(DiskCacheStrategy.NONE) .diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true) .skipMemoryCache(true)
val target = Glide.with(context) val target = Glide.with(activity)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(model) .load(model)
@ -160,11 +182,11 @@ abstract class ImageProvider {
try { try {
var bitmap = target.get() var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(context, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
} }
bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId") bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId")
destinationDocFile.openOutputStream().use { output -> targetDocFile.openOutputStream().use { output ->
if (exportMimeType == MimeTypes.BMP) { if (exportMimeType == MimeTypes.BMP) {
BmpWriter.writeRGB24(bitmap, output) BmpWriter.writeRGB24(bitmap, output)
} else { } else {
@ -179,7 +201,7 @@ abstract class ImageProvider {
Bitmap.CompressFormat.WEBP_LOSSY Bitmap.CompressFormat.WEBP_LOSSY
} }
} else { } else {
@Suppress("DEPRECATION") @Suppress("deprecation")
Bitmap.CompressFormat.WEBP Bitmap.CompressFormat.WEBP
} }
else -> throw Exception("unsupported export MIME type=$exportMimeType") else -> throw Exception("unsupported export MIME type=$exportMimeType")
@ -187,45 +209,75 @@ abstract class ImageProvider {
bitmap.compress(format, quality, output) bitmap.compress(format, quality, output)
} }
} }
} catch (e: Exception) {
// remove empty file
if (targetDocFile.exists()) {
targetDocFile.delete()
}
throw e
} finally { } finally {
Glide.with(context).clear(target) Glide.with(activity).clear(target)
} }
} }
val fileName = destinationDocFile.name val fileName = targetDocFile.name
val destinationFullPath = destinationDir + fileName val targetFullPath = targetDir + fileName
return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType) return MediaStoreImageProvider().scanNewPath(activity, targetFullPath, exportMimeType)
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun captureFrame( suspend fun captureFrame(
context: Context, activity: Activity,
desiredNameWithoutExtension: String, desiredNameWithoutExtension: String,
exifFields: FieldMap, exifFields: FieldMap,
bytes: ByteArray, bytes: ByteArray,
destinationDir: String, targetDir: String,
nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir) val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (destinationDirDocFile == null) { if (!File(targetDir).exists()) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir")) callback.onFailure(Exception("failed to create directory at path=$targetDir"))
return
}
// TODO TLAD [storage] allow inserting by Media Store
if (targetDirDocFile == null) {
callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir"))
return return
} }
val captureMimeType = MimeTypes.JPEG val captureMimeType = MimeTypes.JPEG
val availableNameWithoutExtension = findAvailableFileNameWithoutExtension(destinationDir, desiredNameWithoutExtension, extensionFor(captureMimeType)) val targetNameWithoutExtension = try {
resolveTargetFileNameWithoutExtension(
activity = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = captureMimeType,
conflictStrategy = nameConflictStrategy,
)
} catch (e: Exception) {
callback.onFailure(e)
return
}
if (targetNameWithoutExtension == null) {
// skip it
callback.onSuccess(skippedFieldMap)
return
}
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension) val targetTreeFile = targetDirDocFile.createFile(captureMimeType, targetNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri) val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
try { try {
if (exifFields.isEmpty()) { if (exifFields.isEmpty()) {
destinationDocFile.openOutputStream().use { output -> targetDocFile.openOutputStream().use { output ->
output.write(bytes) output.write(bytes)
} }
} else { } else {
@ -284,55 +336,56 @@ abstract class ImageProvider {
exif.saveAttributes() exif.saveAttributes()
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(destinationDocFile) DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile)
} }
val fileName = destinationDocFile.name val fileName = targetDocFile.name
val destinationFullPath = destinationDir + fileName val targetFullPath = targetDir + fileName
val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType) val newFields = MediaStoreImageProvider().scanNewPath(activity, targetFullPath, captureMimeType)
callback.onSuccess(newFields) callback.onSuccess(newFields)
} catch (e: Exception) { } catch (e: Exception) {
callback.onFailure(e) callback.onFailure(e)
} }
} }
private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String { // returns available name to use, or `null` to skip it
suspend fun resolveTargetFileNameWithoutExtension(
activity: Activity,
dir: String,
desiredNameWithoutExtension: String,
mimeType: String,
conflictStrategy: NameConflictStrategy,
): String? {
val extension = extensionFor(mimeType)
val targetFile = File(dir, "$desiredNameWithoutExtension$extension")
return when (conflictStrategy) {
NameConflictStrategy.RENAME -> {
var nameWithoutExtension = desiredNameWithoutExtension var nameWithoutExtension = desiredNameWithoutExtension
var i = 0 var i = 0
while (File(dir, "$nameWithoutExtension$extension").exists()) { while (File(dir, "$nameWithoutExtension$extension").exists()) {
i++ i++
nameWithoutExtension = "$desiredNameWithoutExtension ($i)" nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
} }
return nameWithoutExtension nameWithoutExtension
} }
NameConflictStrategy.REPLACE -> {
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) { if (targetFile.exists()) {
val oldFile = File(oldPath) val path = targetFile.path
val newFile = File(oldFile.parent, newFilename) MediaStoreImageProvider().apply {
if (oldFile == newFile) { val uri = getContentUriForPath(activity, path)
Log.w(LOG_TAG, "new name and old name are the same, path=$oldPath") uri ?: throw Exception("failed to find content URI for path=$path")
callback.onSuccess(HashMap()) delete(activity, uri, path, mimeType)
return
} }
val df = getDocumentFile(context, oldPath, oldMediaUri)
try {
@Suppress("BlockingMethodInNonBlockingContext")
val renamed = df != null && df.renameTo(newFilename)
if (!renamed) {
callback.onFailure(Exception("failed to rename entry at path=$oldPath"))
return
} }
} catch (e: FileNotFoundException) { desiredNameWithoutExtension
callback.onFailure(e) }
return NameConflictStrategy.SKIP -> {
if (targetFile.exists()) {
null
} else {
desiredNameWithoutExtension
}
} }
scanObsoletePath(context, oldPath, mimeType)
try {
callback.onSuccess(MediaStoreImageProvider().scanNewPath(context, newFile.path, mimeType))
} catch (e: Exception) {
callback.onFailure(e)
} }
} }
@ -350,12 +403,6 @@ abstract class ImageProvider {
return false return false
} }
val originalDocumentFile = getDocumentFile(context, path, uri)
if (originalDocumentFile == null) {
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
return false
}
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
var videoBytes: ByteArray? = null var videoBytes: ByteArray? = null
@ -381,7 +428,7 @@ abstract class ImageProvider {
} }
} else { } else {
// copy original file to a temporary file for editing // copy original file to a temporary file for editing
originalDocumentFile.openInputStream().use { imageInput -> StorageUtils.openInputStream(context, uri)?.use { imageInput ->
imageInput.copyTo(output) imageInput.copyTo(output)
} }
} }
@ -401,7 +448,7 @@ abstract class ImageProvider {
} }
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false return false
@ -428,18 +475,12 @@ abstract class ImageProvider {
return false return false
} }
val originalDocumentFile = getDocumentFile(context, path, uri)
if (originalDocumentFile == null) {
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
return false
}
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = File.createTempFile("aves", null).apply { val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit() deleteOnExit()
try { try {
val xmp = originalDocumentFile.openInputStream().use { input -> PixyMetaHelper.getXmp(input) } val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
if (xmp == null) { if (xmp == null) {
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
return false return false
@ -447,7 +488,7 @@ abstract class ImageProvider {
outputStream().use { output -> outputStream().use { output ->
// reopen input to read from start // reopen input to read from start
originalDocumentFile.openInputStream().use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
val editedXmpString = edit(xmp.xmpDocString()) val editedXmpString = edit(xmp.xmpDocString())
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
@ -461,7 +502,7 @@ abstract class ImageProvider {
try { try {
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return false return false
@ -476,7 +517,7 @@ abstract class ImageProvider {
// A few bytes are sometimes appended when writing to a document output stream. // A few bytes are sometimes appended when writing to a document output stream.
// In that case, we need to adjust the trailer video offset accordingly and rewrite the file. // In that case, we need to adjust the trailer video offset accordingly and rewrite the file.
// return whether the file at `path` is fine // returns whether the file at `path` is fine
private fun checkTrailerOffset( private fun checkTrailerOffset(
context: Context, context: Context,
path: String, path: String,
@ -635,7 +676,7 @@ abstract class ImageProvider {
} }
if (success) { if (success) {
scanPostMetadataEdit(context, path, uri, mimeType, HashMap<String, Any?>(), callback) scanPostMetadataEdit(context, path, uri, mimeType, HashMap(), callback)
} }
} }
@ -652,12 +693,6 @@ abstract class ImageProvider {
return return
} }
val originalDocumentFile = getDocumentFile(context, path, uri)
if (originalDocumentFile == null) {
callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri"))
return
}
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
val editableFile = File.createTempFile("aves", null).apply { val editableFile = File.createTempFile("aves", null).apply {
@ -665,7 +700,7 @@ abstract class ImageProvider {
try { try {
outputStream().use { output -> outputStream().use { output ->
// reopen input to read from start // reopen input to read from start
originalDocumentFile.openInputStream().use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->
PixyMetaHelper.removeMetadata(input, output, types) PixyMetaHelper.removeMetadata(input, output, types)
} }
} }
@ -678,7 +713,7 @@ abstract class ImageProvider {
try { try {
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return return
@ -692,6 +727,22 @@ abstract class ImageProvider {
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
} }
private fun copyTo(
context: Context,
mimeType: String,
sourceFile: File,
targetUri: Uri,
targetPath: String
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaUriPermissionGranted(context, targetUri, mimeType)) {
val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri")
DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream)
} else {
val targetDocumentFile = StorageUtils.getDocumentFile(context, targetPath, targetUri) ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
DocumentFileCompat.fromFile(sourceFile).copyTo(targetDocumentFile)
}
}
interface ImageOpCallback { interface ImageOpCallback {
fun onSuccess(fields: FieldMap) fun onSuccess(fields: FieldMap)
fun onFailure(throwable: Throwable) fun onFailure(throwable: Throwable)
@ -700,6 +751,21 @@ abstract class ImageProvider {
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<ImageProvider>() private val LOG_TAG = LogUtils.createTag<ImageProvider>()
val FILE_EXTENSION_PATTERN = Regex("[.][^.]+$")
val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP) val supportedExportMimeTypes = listOf(MimeTypes.BMP, MimeTypes.JPEG, MimeTypes.PNG, MimeTypes.WEBP)
// used when skipping a move/creation op because the target file already exists
val skippedFieldMap: HashMap<String, Any?> = hashMapOf("skipped" to true)
@RequiresApi(Build.VERSION_CODES.Q)
fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean {
val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType)
val pid = Binder.getCallingPid()
val uid = Binder.getCallingUid()
val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED
}
} }
} }

View file

@ -4,25 +4,29 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.RecoverableSecurityException import android.app.RecoverableSecurityException
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.extensionFor
import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator import deckers.thibault.aves.utils.StorageUtils.PathSegments
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File import java.io.File
import java.util.* import java.util.*
@ -222,40 +226,64 @@ class MediaStoreImageProvider : ImageProvider() {
return found return found
} }
private fun hasEntry(context: Context, contentUri: Uri): Boolean {
var found = false
val projection = arrayOf(MediaStore.MediaColumns._ID)
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, null)
if (cursor != null) {
while (cursor.moveToNext()) {
found = true
}
cursor.close()
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get entry at contentUri=$contentUri", e)
}
return found
}
private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType
// `uri` is a media URI, not a document URI // `uri` is a media URI, not a document URI
override suspend fun delete(activity: Activity, uri: Uri, path: String?) { override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
&& isMediaUriPermissionGranted(activity, uri, mimeType))
) {
// if the file is on SD card, calling the content resolver `delete()`
// removes the entry from the Media Store but it doesn't delete the file,
// even when the app has the permission, so we manually delete the document file
path ?: throw Exception("failed to delete file because path is null") path ?: throw Exception("failed to delete file because path is null")
if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) {
if (File(path).exists() && requireAccessPermission(activity, path)) { Log.d(LOG_TAG, "delete document at uri=$uri path=$path")
// if the file is on SD card, calling the content resolver `delete()` removes the entry from the Media Store val df = StorageUtils.getDocumentFile(activity, path, uri)
// but it doesn't delete the file, even if the app has the permission
val df = getDocumentFile(activity, path, uri)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) return if (df != null && df.delete()) return
throw Exception("failed to delete file with df=$df") throw Exception("failed to delete file with df=$df")
} }
}
try { try {
Log.d(LOG_TAG, "delete content at uri=$uri")
if (activity.contentResolver.delete(uri, null, null) > 0) return if (activity.contentResolver.delete(uri, null, null) > 0) return
} catch (securityException: SecurityException) { } catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory, // even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+ // the delete request may yield a `RecoverableSecurityException` on Android 10+
// when the underlying file no longer exists and this is an orphaned entry in the Media Store // when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException)
val rse = securityException as? RecoverableSecurityException ?: throw securityException val rse = securityException as? RecoverableSecurityException ?: throw securityException
val intentSender = rse.userAction.actionIntent.intentSender val intentSender = rse.userAction.actionIntent.intentSender
// request user permission for this item // request user permission for this item
pendingDeleteCompleter = CompletableFuture<Boolean>() MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture<Boolean>()
activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null) activity.startIntentSenderForResult(intentSender, DELETE_SINGLE_PERMISSION_REQUEST, null, 0, 0, 0, null)
val granted = pendingDeleteCompleter!!.join() val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join()
pendingDeleteCompleter = null MainActivity.pendingScopedStoragePermissionCompleter = null
if (granted) { if (granted) {
delete(activity, uri, path) delete(activity, uri, path, mimeType)
} else { } else {
throw Exception("failed to get delete permission") throw Exception("failed to get delete permission")
} }
@ -269,13 +297,14 @@ class MediaStoreImageProvider : ImageProvider() {
override suspend fun moveMultiple( override suspend fun moveMultiple(
activity: Activity, activity: Activity,
copy: Boolean, copy: Boolean,
destinationDir: String, targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>, entries: List<AvesEntry>,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (destinationDirDocFile == null) { if (!File(targetDir).exists()) {
callback.onFailure(Exception("failed to create directory at path=$destinationDir")) callback.onFailure(Exception("failed to create directory at path=$targetDir"))
return return
} }
@ -284,7 +313,7 @@ class MediaStoreImageProvider : ImageProvider() {
val sourcePath = entry.path val sourcePath = entry.path
val mimeType = entry.mimeType val mimeType = entry.mimeType
val result = hashMapOf<String, Any?>( val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(), "uri" to sourceUri.toString(),
"success" to false, "success" to false,
) )
@ -305,77 +334,272 @@ class MediaStoreImageProvider : ImageProvider() {
// - there is no documentation regarding support for usage with removable storage // - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try { try {
val newFields = moveSingleByTreeDocAndScan( val newFields = moveSingle(
activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy, activity = activity,
sourcePath = sourcePath,
sourceUri = sourceUri,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
nameConflictStrategy = nameConflictStrategy,
mimeType = mimeType,
copy = copy,
) )
result["newFields"] = newFields result["newFields"] = newFields
result["success"] = true result["success"] = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
} }
} }
callback.onSuccess(result) callback.onSuccess(result)
} }
} }
private suspend fun moveSingleByTreeDocAndScan( private suspend fun moveSingle(
activity: Activity, activity: Activity,
sourcePath: String, sourcePath: String,
sourceUri: Uri, sourceUri: Uri,
destinationDir: String, targetDir: String,
destinationDirDocFile: DocumentFileCompat, targetDirDocFile: DocumentFileCompat?,
nameConflictStrategy: NameConflictStrategy,
mimeType: String, mimeType: String,
copy: Boolean, copy: Boolean,
): FieldMap { ): FieldMap {
val sourceFile = File(sourcePath) val sourceFile = File(sourcePath)
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) }
if (sourceDir == destinationDir) { if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
if (copy) throw Exception("file at path=$sourcePath is already in destination directory") // nothing to do unless it's a renamed copy
return HashMap<String, Any?>() return skippedFieldMap
} }
val sourceFileName = sourceFile.name val sourceFileName = sourceFile.name
val desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "") val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity,
dir = targetDir,
desiredNameWithoutExtension = desiredNameWithoutExtension,
mimeType = mimeType,
conflictStrategy = nameConflictStrategy,
) ?: return skippedFieldMap
if (File(destinationDir, sourceFileName).exists()) { return moveSingleByTreeDoc(
throw Exception("file with name=$sourceFileName already exists in destination directory") activity = activity,
mimeType = mimeType,
sourceUri = sourceUri,
sourcePath = sourcePath,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
copy = copy
)
} }
private suspend fun moveSingleByTreeDoc(
activity: Activity,
mimeType: String,
sourceUri: Uri,
sourcePath: String,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
targetNameWithoutExtension: String,
copy: Boolean
): FieldMap {
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri`
val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
val targetPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) {
val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}"
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName)
put(MediaStore.MediaColumns.IS_PENDING, 1)
}
val resolver = activity.contentResolver
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
uri?.let {
@Suppress("BlockingMethodInNonBlockingContext")
resolver.openOutputStream(uri)?.use { output ->
source.copyTo(output)
}
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
} ?: throw Exception("MediaStore failed for some reason")
File(targetDir, targetFileName).path
} else {
targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir")
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
// but in order to open an output stream to it, we need to use a `SingleDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile`
// through a document URI, not a tree URI // through a document URI, not a tree URI
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension) val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
source.copyTo(destinationDocFile) source.copyTo(targetDocFile)
// the source file name and the created document file name can be different when: // the source file name and the created document file name can be different when:
// - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not* // - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not*
// - the original extension does not match the extension added by the underlying provider // - the original extension does not match the extension added by the underlying provider
val fileName = destinationDocFile.name val fileName = targetDocFile.name
val destinationFullPath = destinationDir + fileName targetDir + fileName
}
var deletedSource = false
if (!copy) { if (!copy) {
// delete original entry // delete original entry
try { try {
delete(activity, sourceUri, sourcePath) delete(activity, sourceUri, sourcePath, mimeType)
deletedSource = true
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
} }
} }
return scanNewPath(activity, destinationFullPath, mimeType).apply { return scanNewPath(activity, targetPath, mimeType)
put("deletedSource", deletedSource)
} }
private fun isDownloadDir(context: Context, dirPath: String): Boolean {
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
if (relativeDir.endsWith(File.separator)) {
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
}
return relativeDir == Environment.DIRECTORY_DOWNLOADS
}
override suspend fun renameMultiple(
activity: Activity,
newFileName: String,
entries: List<AvesEntry>,
callback: ImageOpCallback,
) {
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
val mimeType = entry.mimeType
val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(),
"success" to false,
)
if (sourcePath != null) {
try {
val newFields = renameSingle(
activity = activity,
mimeType = mimeType,
oldMediaUri = sourceUri,
oldPath = sourcePath,
newFileName = newFileName,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to rename to newFileName=$newFileName entry with sourcePath=$sourcePath", e)
}
}
callback.onSuccess(result)
}
}
private suspend fun renameSingle(
activity: Activity,
mimeType: String,
oldMediaUri: Uri,
oldPath: String,
newFileName: String,
): FieldMap {
val oldFile = File(oldPath)
val newFile = File(oldFile.parent, newFileName)
if (oldFile == newFile) {
// nothing to do
return skippedFieldMap
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
&& isMediaUriPermissionGranted(activity, oldMediaUri, mimeType)
) {
renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile)
} else {
renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private suspend fun renameSingleByMediaStore(
activity: Activity,
mimeType: String,
mediaUri: Uri,
newFile: File
): FieldMap {
val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType)
// `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME`
val tempValues = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 1) }
if (activity.contentResolver.update(uri, tempValues, null, null) == 0) {
throw Exception("failed to update fields for uri=$uri")
}
val finalValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, newFile.name)
// scanning the new file will not automatically update `TITLE`
put(MediaStore.MediaColumns.TITLE, newFile.nameWithoutExtension)
put(MediaStore.MediaColumns.IS_PENDING, 0)
}
if (activity.contentResolver.update(uri, finalValues, null, null) == 0) {
throw Exception("failed to update fields for uri=$uri")
}
// URI should not change
return scanNewPath(activity, newFile.path, mimeType)
}
private suspend fun renameSingleByTreeDoc(
activity: Activity,
mimeType: String,
oldMediaUri: Uri,
oldPath: String,
newFile: File
): FieldMap {
Log.d(LOG_TAG, "rename document at uri=$oldMediaUri path=$oldPath")
@Suppress("BlockingMethodInNonBlockingContext")
val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false
if (!renamed) {
throw Exception("failed to rename entry at path=$oldPath")
}
// Renaming may be successful and the file at the old path no longer exists
// but, in some situations, scanning the old path does not clear the Media Store entry.
// For higher chance of accurate obsolete item check, keep this order:
// 1) scan obsolete item,
// 2) scan current item,
// 3) check obsolete item in Media Store
scanObsoletePath(activity, oldPath, mimeType)
val newFields = scanNewPath(activity, newFile.path, mimeType)
if (hasEntry(activity, oldMediaUri)) {
Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFile=$newFile did not clear the MediaStore entry for obsolete path=$oldPath")
// On Android Q (emulator/Mi9TPro), the concept of owner package disrupts renaming and the Media Store keeps an obsolete entry,
// but we use legacy external storage, so at least we do not have to deal with a `RecoverableSecurityException`
// when deleting this obsolete entry which is not backed by a file anymore.
// On Android R (S10e), everything seems fine!
// On Android S (emulator), renaming always leaves an obsolete entry whatever the owner package,
// and we get a `RecoverableSecurityException` if we attempt to delete this obsolete entry,
// but the entry seems to be cleaned later automatically by the Media Store anyway.
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
try {
delete(activity, oldMediaUri, oldPath, mimeType)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e)
}
}
}
return newFields
} }
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) { override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: HashMap<String, Any?>, callback: ImageOpCallback) {
@ -445,9 +669,9 @@ class MediaStoreImageProvider : ImageProvider() {
val contentId = newUri.tryParseId() val contentId = newUri.tryParseId()
if (contentId != null) { if (contentId != null) {
if (isImage(mimeType)) { if (isImage(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId) contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
} else if (isVideo(mimeType)) { } else if (isVideo(mimeType)) {
contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId) contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId)
} }
} }
@ -462,6 +686,29 @@ class MediaStoreImageProvider : ImageProvider() {
} }
} }
fun getContentUriForPath(context: Context, path: String): Uri? {
val projection = arrayOf(MediaStore.MediaColumns._ID)
val selection = "${MediaColumns.PATH} = ?"
val selectionArgs = arrayOf(path)
fun check(context: Context, contentUri: Uri): Uri? {
var mediaContentUri: Uri? = null
try {
val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(MediaStore.MediaColumns._ID).let {
if (it != -1) mediaContentUri = ContentUris.withAppendedId(contentUri, cursor.getLong(it))
}
cursor.close()
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get URI for contentUri=$contentUri path=$path", e)
}
return mediaContentUri
}
return check(context, IMAGE_CONTENT_URI) ?: check(context, VIDEO_CONTENT_URI)
}
companion object { companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>() private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
@ -494,8 +741,6 @@ class MediaStoreImageProvider : ImageProvider() {
MediaStore.MediaColumns.ORIENTATION, MediaStore.MediaColumns.ORIENTATION,
) else emptyArray() ) else emptyArray()
) )
var pendingDeleteCompleter: CompletableFuture<Boolean>? = null
} }
} }
@ -513,7 +758,7 @@ object MediaColumns {
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
const val DURATION = MediaStore.MediaColumns.DURATION const val DURATION = MediaStore.MediaColumns.DURATION
@Suppress("DEPRECATION") @Suppress("deprecation")
const val PATH = MediaStore.MediaColumns.DATA const val PATH = MediaStore.MediaColumns.DATA
} }

View file

@ -0,0 +1,42 @@
package deckers.thibault.aves.utils
import android.app.ActivityManager
import android.app.Service
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
object ContextUtils {
fun Context.resourceUri(resourceId: Int): Uri = with(resources) {
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(getResourcePackageName(resourceId))
.appendPath(getResourceTypeName(resourceId))
.appendPath(getResourceEntryName(resourceId))
.build()
}
suspend fun Context.runOnUiThread(r: Runnable) {
if (Looper.myLooper() != mainLooper) {
suspendCoroutine<Boolean> { cont ->
Handler(mainLooper).post {
r.run()
cont.resume(true)
}
}
} else {
r.run()
}
}
fun Context.isMyServiceRunning(serviceClass: Class<out Service>): Boolean {
val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager?
am ?: return false
@Suppress("deprecation")
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
}
}

View file

@ -0,0 +1,49 @@
package deckers.thibault.aves.utils
import android.content.Context
import android.util.Log
import deckers.thibault.aves.utils.ContextUtils.runOnUiThread
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.embedding.engine.loader.FlutterLoader
import io.flutter.view.FlutterCallbackInformation
object FlutterUtils {
private val LOG_TAG = LogUtils.createTag<FlutterUtils>()
suspend fun initFlutterEngine(context: Context, sharedPreferencesKey: String, callbackHandleKey: String, engineSetter: (engine: FlutterEngine) -> Unit) {
val callbackHandle = context.getSharedPreferences(sharedPreferencesKey, Context.MODE_PRIVATE).getLong(callbackHandleKey, 0)
if (callbackHandle == 0L) {
Log.e(LOG_TAG, "failed to retrieve registered callback handle for sharedPreferencesKey=$sharedPreferencesKey callbackHandleKey=$callbackHandleKey")
return
}
lateinit var flutterLoader: FlutterLoader
context.runOnUiThread {
// initialization must happen on the main thread
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
startInitialization(context)
ensureInitializationComplete(context, null)
}
}
val callbackInfo = FlutterCallbackInformation.lookupCallbackInformation(callbackHandle)
if (callbackInfo == null) {
Log.e(LOG_TAG, "failed to find callback information for sharedPreferencesKey=$sharedPreferencesKey callbackHandleKey=$callbackHandleKey")
return
}
val args = DartExecutor.DartCallback(
context.assets,
flutterLoader.findAppBundlePath(),
callbackInfo
)
context.runOnUiThread {
val engine = FlutterEngine(context).apply {
dartExecutor.executeDartCallback(args)
}
engineSetter(engine)
}
}
}

View file

@ -1,20 +1,20 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import java.util.regex.Pattern
object LogUtils { object LogUtils {
const val LOG_TAG_MAX_LENGTH = 23 const val LOG_TAG_MAX_LENGTH = 23
val LOG_TAG_PACKAGE_PATTERN: Pattern = Pattern.compile("(\\w)(\\w*)\\.")
val LOG_TAG_PACKAGE_PATTERN = Regex("(\\w)(\\w*)\\.")
val LOWER_CASE_PATTERN = Regex("[a-z]")
// create an Android logger friendly log tag for the specified class // create an Android logger friendly log tag for the specified class
inline fun <reified T> createTag(): String { inline fun <reified T> createTag(): String {
val kClass = T::class val kClass = T::class
// shorten class name to "a.b.CccDdd" // shorten class name to "a.b.CccDdd"
var logTag = LOG_TAG_PACKAGE_PATTERN.matcher(kClass.qualifiedName!!).replaceAll("$1.") var logTag = LOG_TAG_PACKAGE_PATTERN.replace(kClass.qualifiedName!!, "$1.")
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "a.b.CD" // shorten class name to "a.b.CD"
val simpleName = kClass.simpleName!! val simpleName = kClass.simpleName!!
val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "") val shortSimpleName = simpleName.replace(LOWER_CASE_PATTERN, "")
logTag = logTag.replace(simpleName, shortSimpleName) logTag = logTag.replace(simpleName, shortSimpleName)
if (logTag.length > LOG_TAG_MAX_LENGTH) { if (logTag.length > LOG_TAG_MAX_LENGTH) {
// shorten class name to "CD" // shorten class name to "CD"

View file

@ -23,21 +23,34 @@ object MimeTypes {
// raw raster // raw raster
private const val ARW = "image/x-sony-arw" private const val ARW = "image/x-sony-arw"
private const val CR2 = "image/x-canon-cr2" private const val CR2 = "image/x-canon-cr2"
private const val CRW = "image/x-canon-crw"
private const val DCR = "image/x-kodak-dcr"
private const val DNG = "image/x-adobe-dng" private const val DNG = "image/x-adobe-dng"
private const val ERF = "image/x-epson-erf"
private const val K25 = "image/x-kodak-k25"
private const val KDC = "image/x-kodak-kdc"
private const val MRW = "image/x-minolta-mrw"
private const val NEF = "image/x-nikon-nef" private const val NEF = "image/x-nikon-nef"
private const val NRW = "image/x-nikon-nrw" private const val NRW = "image/x-nikon-nrw"
private const val ORF = "image/x-olympus-orf" private const val ORF = "image/x-olympus-orf"
private const val PEF = "image/x-pentax-pef" private const val PEF = "image/x-pentax-pef"
private const val RAF = "image/x-fuji-raf" private const val RAF = "image/x-fuji-raf"
private const val RAW = "image/x-panasonic-raw"
private const val RW2 = "image/x-panasonic-rw2" private const val RW2 = "image/x-panasonic-rw2"
private const val SR2 = "image/x-sony-sr2"
private const val SRF = "image/x-sony-srf"
private const val SRW = "image/x-samsung-srw" private const val SRW = "image/x-samsung-srw"
private const val X3F = "image/x-sigma-x3f"
// vector // vector
const val SVG = "image/svg+xml" const val SVG = "image/svg+xml"
private const val VIDEO = "video" private const val VIDEO = "video"
private const val AVI = "video/avi"
private const val AVI_VND = "video/vnd.avi"
private const val MKV = "video/x-matroska" private const val MKV = "video/x-matroska"
private const val MOV = "video/quicktime"
private const val MP2T = "video/mp2t" private const val MP2T = "video/mp2t"
private const val MP2TS = "video/mp2ts" private const val MP2TS = "video/mp2ts"
const val MP4 = "video/mp4" const val MP4 = "video/mp4"
@ -125,16 +138,47 @@ object MimeTypes {
// extensions // extensions
fun extensionFor(mimeType: String): String? = when (mimeType) { fun extensionFor(mimeType: String): String? = when (mimeType) {
ARW -> ".arw"
AVI, AVI_VND -> ".avi"
BMP -> ".bmp" BMP -> ".bmp"
CR2 -> ".cr2"
CRW -> ".crw"
DCR -> ".dcr"
DJVU -> ".djvu"
DNG -> ".dng"
ERF -> ".erf"
GIF -> ".gif" GIF -> ".gif"
HEIC, HEIF -> ".heif" HEIC, HEIF -> ".heif"
ICO -> ".ico"
JPEG -> ".jpg" JPEG -> ".jpg"
K25 -> ".k25"
KDC -> ".kdc"
MKV -> ".mkv"
MOV -> ".mov"
MP2T, MP2TS -> ".m2ts"
MP4 -> ".mp4" MP4 -> ".mp4"
MRW -> ".mrw"
NEF -> ".nef"
NRW -> ".nrw"
OGV -> ".ogv"
ORF -> ".orf"
PEF -> ".pef"
PNG -> ".png" PNG -> ".png"
PSD_VND, PSD_X -> ".psd"
RAF -> ".raf"
RAW -> ".raw"
RW2 -> ".rw2"
SR2 -> ".sr2"
SRF -> ".srf"
SRW -> ".srw"
SVG -> ".svg"
TIFF -> ".tiff" TIFF -> ".tiff"
WBMP -> ".wbmp"
WEBM -> ".webm"
WEBP -> ".webp" WEBP -> ".webp"
X3F -> ".x3f"
else -> null else -> null
} }
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
} }

View file

@ -3,24 +3,35 @@ package deckers.thibault.aves.utils
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Binder
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File import java.io.File
import java.util.* import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
object PermissionManager { object PermissionManager {
private val LOG_TAG = LogUtils.createTag<PermissionManager>() private val LOG_TAG = LogUtils.createTag<PermissionManager>()
private val MEDIA_STORE_INSERTION_PRIMARY_DIRS = listOf(
Environment.DIRECTORY_DCIM,
Environment.DIRECTORY_DOWNLOADS,
Environment.DIRECTORY_PICTURES,
)
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun requestVolumeAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) {
Log.i(LOG_TAG, "request user to select and grant access permission to path=$path") Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
var intent: Intent? = null var intent: Intent? = null
@ -43,6 +54,35 @@ object PermissionManager {
} }
} }
@RequiresApi(Build.VERSION_CODES.R)
fun requestMediaFileAccess(activity: Activity, uris: List<Uri>, mimeTypes: List<String>): Boolean {
val safeUris = uris.mapIndexed { index, uri -> StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeTypes[index]) }
val todoUris = ArrayList<Uri>()
val pid = Binder.getCallingPid()
val uid = Binder.getCallingUid()
val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
activity.checkUriPermissions(safeUris, pid, uid, flags)
} else {
safeUris.map { activity.checkUriPermission(it, pid, uid, flags) }.toIntArray()
}.forEachIndexed { index, permission ->
if (permission != PackageManager.PERMISSION_GRANTED) {
todoUris.add(safeUris[index])
}
}
if (todoUris.isEmpty()) return true
Log.i(LOG_TAG, "request user to select and grant access permission to uris=$todoUris")
val intentSender = MediaStore.createWriteRequest(activity.contentResolver, safeUris).intentSender
MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture<Boolean>()
activity.startIntentSenderForResult(intentSender, MainActivity.MEDIA_WRITE_BULK_PERMISSION_REQUEST, null, 0, 0, 0, null)
val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join()
MainActivity.pendingScopedStoragePermissionCompleter = null
return granted
}
fun getGrantedDirForPath(context: Context, anyPath: String): String? { fun getGrantedDirForPath(context: Context, anyPath: String): String? {
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
} }
@ -130,6 +170,18 @@ object PermissionManager {
return dirs return dirs
} }
fun canInsertByMediaStore(directories: List<FieldMap>): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directories.all {
val relativeDir = it["relativeDir"] as String
val segments = relativeDir.split(File.separator)
segments.isNotEmpty() && MEDIA_STORE_INSERTION_PRIMARY_DIRS.contains(segments.first())
}
} else {
true
}
}
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun revokeDirectoryAccess(context: Context, path: String): Boolean { fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let { return StorageUtils.convertDirPathToTreeUri(context, path)?.let {

View file

@ -23,12 +23,16 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream
import java.util.* import java.util.*
import java.util.regex.Pattern import java.util.regex.Pattern
object StorageUtils { object StorageUtils {
private val LOG_TAG = LogUtils.createTag<StorageUtils>() private val LOG_TAG = LogUtils.createTag<StorageUtils>()
private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/"
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
/** /**
* Volume paths * Volume paths
*/ */
@ -269,8 +273,8 @@ object StorageUtils {
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/ // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? { fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length) val encoded = treeUri.toString().substring(TREE_URI_ROOT.length)
val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded)) val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded))
with(matcher) { with(matcher) {
if (find()) { if (find()) {
val uuid = group(1) val uuid = group(1)
@ -329,7 +333,7 @@ object StorageUtils {
// returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise) // returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise)
// returns null if directory does not exist and could not be created // returns null if directory does not exist and could not be created
fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { fun createDirectoryDocIfAbsent(context: Context, dirPath: String): DocumentFileCompat? {
val cleanDirPath = ensureTrailingSeparator(dirPath) val cleanDirPath = ensureTrailingSeparator(dirPath)
return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
@ -427,7 +431,14 @@ object StorageUtils {
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some content URIs (e.g. `content://media/external_primary/downloads/...`) // for some content URIs (e.g. `content://media/external_primary/downloads/...`)
// so we build a typical `images` or `videos` content URI from the original content ID. // so we build a typical `images` or `videos` content URI from the original content ID.
fun getGlideSafeUri(uri: Uri, mimeType: String): Uri { fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
// requesting access or writing to some MediaStore content URIs
// e.g. `content://0@media/...`, `content://media/external_primary/downloads/...`
// yields an exception with `All requested items must be referenced by specific ID`
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
// we cannot safely apply this to a file content URI, as it may point to a file not indexed // we cannot safely apply this to a file content URI, as it may point to a file not indexed
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI // by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI
@ -439,7 +450,11 @@ object StorageUtils {
else -> uri else -> uri
} }
} }
} else if (uri.userInfo != null) {
// strip user info, if any
return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
} }
} }
return uri return uri
} }
@ -448,11 +463,22 @@ object StorageUtils {
val effectiveUri = getOriginalUri(context, uri) val effectiveUri = getOriginalUri(context, uri)
return try { return try {
context.contentResolver.openInputStream(effectiveUri) context.contentResolver.openInputStream(effectiveUri)
} catch (e: FileNotFoundException) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to find file at uri=$effectiveUri") // among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e)
null null
} catch (e: SecurityException) { }
Log.w(LOG_TAG, "failed to open file at uri=$effectiveUri", e) }
fun openOutputStream(context: Context, uri: Uri, mimeType: String): OutputStream? {
val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
return try {
context.contentResolver.openOutputStream(effectiveUri)
} catch (e: Exception) {
// among various other exceptions,
// opening a file marked pending and owned by another package throws an `IllegalStateException`
Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri", e)
null null
} }
} }
@ -467,7 +493,7 @@ object StorageUtils {
} }
} catch (e: Exception) { } catch (e: Exception) {
// unsupported format // unsupported format
Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri") Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri effectiveUri=$effectiveUri")
null null
} }
} }
@ -482,7 +508,7 @@ object StorageUtils {
class PathSegments(context: Context, fullPath: String) { class PathSegments(context: Context, fullPath: String) {
var volumePath: String? = null // `volumePath` with trailing "/" var volumePath: String? = null // `volumePath` with trailing "/"
var relativeDir: String? = null // `relativeDir` with trailing "/" var relativeDir: String? = null // `relativeDir` with trailing "/"
private var fileName: String? = null // null for directories var fileName: String? = null // null for directories
init { init {
volumePath = getVolumePath(context, fullPath) volumePath = getVolumePath(context, fullPath)

View file

@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:pathData="M3.925,16.034 L60.825,72.933a2.421,2.421 0.001,0 0,3.423 0l10.604,-10.603a6.789,6.789 90.001,0 0,0 -9.601L34.066,11.942A8.264,8.264 22.5,0 0,28.222 9.522H6.623A3.815,3.815 112.5,0 0,3.925 16.034Z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:pathData="m36.36,65.907v28.743a2.557,2.557 22.5,0 0,4.364 1.808L53.817,83.364a6.172,6.172 90,0 0,0 -8.729L42.532,63.35a3.616,3.616 157.5,0 0,-6.172 2.557z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:pathData="M79.653,40.078V11.335A2.557,2.557 22.5,0 0,75.289 9.527L62.195,22.62a6.172,6.172 90,0 0,0 8.729l11.285,11.285a3.616,3.616 157.5,0 0,6.172 -2.557z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:pathData="M96.613,16.867 L89.085,9.339a1.917,1.917 157.5,0 0,-3.273 1.356v6.172a4.629,4.629 45,0 0,4.629 4.629h4.255a2.712,2.712 112.5,0 0,1.917 -4.629z"
android:strokeWidth="5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M16,8v8H8V8h8m2,-2H6v12h12V6z"/>
</vector>

View file

@ -3,4 +3,8 @@
<string name="app_name">아베스</string> <string name="app_name">아베스</string>
<string name="search_shortcut_short_label">검색</string> <string name="search_shortcut_short_label">검색</string>
<string name="videos_shortcut_short_label">동영상</string> <string name="videos_shortcut_short_label">동영상</string>
<string name="analysis_channel_name">미디어 분석</string>
<string name="analysis_service_description">사진과 동영상 분석</string>
<string name="analysis_notification_default_title">미디어 분석</string>
<string name="analysis_notification_action_stop">취소</string>
</resources> </resources>

View file

@ -3,4 +3,8 @@
<string name="app_name">Aves</string> <string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Search</string> <string name="search_shortcut_short_label">Search</string>
<string name="videos_shortcut_short_label">Videos</string> <string name="videos_shortcut_short_label">Videos</string>
<string name="analysis_channel_name">Media scan</string>
<string name="analysis_service_description">Scan images &amp; videos</string>
<string name="analysis_notification_default_title">Scanning media</string>
<string name="analysis_notification_action_stop">Stop</string>
</resources> </resources>

View file

@ -1,32 +0,0 @@
import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
String _decimal2sexagesimal(final double degDecimal) {
List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value
// has no decimal point (only necessary for browser)
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
return <int>[
int.parse(tmp[0]).abs(),
int.parse(tmp[1]),
];
}
final deg = _split(degDecimal)[0];
final minDecimal = (degDecimal.abs() - deg) * 60;
final min = _split(minDecimal)[0];
final sec = (minDecimal - min) * 60;
return '$deg° $min ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}';
}
// returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
List<String> toDMS(LatLng latLng) {
final lat = latLng.latitude;
final lng = latLng.longitude;
return [
'${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}',
'${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}',
];
}

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui show Codec; import 'dart:ui' as ui show Codec;
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -39,7 +39,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async { Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
try { try {
final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size); final bytes = await androidAppService.getAppIcon(key.packageName, key.size);
return await decode(bytes.isEmpty ? kTransparentImage : bytes); return await decode(bytes.isEmpty ? kTransparentImage : bytes);
} catch (error) { } catch (error) {
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error'); debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');

View file

@ -62,8 +62,10 @@
"@sourceStateLoading": {}, "@sourceStateLoading": {},
"sourceStateCataloguing": "Cataloguing", "sourceStateCataloguing": "Cataloguing",
"@sourceStateCataloguing": {}, "@sourceStateCataloguing": {},
"sourceStateLocating": "Locating", "sourceStateLocatingCountries": "Locating countries",
"@sourceStateLocating": {}, "@sourceStateLocatingCountries": {},
"sourceStateLocatingPlaces": "Locating places",
"@sourceStateLocatingPlaces": {},
"chipActionDelete": "Delete", "chipActionDelete": "Delete",
"@chipActionDelete": {}, "@chipActionDelete": {},
@ -159,6 +161,8 @@
"@filterTypeMotionPhotoLabel": {}, "@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panorama", "filterTypePanoramaLabel": "Panorama",
"@filterTypePanoramaLabel": {}, "@filterTypePanoramaLabel": {},
"filterTypeRawLabel": "Raw",
"@filterTypeRawLabel": {},
"filterTypeSphericalVideoLabel": "360° Video", "filterTypeSphericalVideoLabel": "360° Video",
"@filterTypeSphericalVideoLabel": {}, "@filterTypeSphericalVideoLabel": {},
"filterTypeGeotiffLabel": "GeoTIFF", "filterTypeGeotiffLabel": "GeoTIFF",
@ -173,6 +177,11 @@
"coordinateFormatDecimal": "Decimal degrees", "coordinateFormatDecimal": "Decimal degrees",
"@coordinateFormatDecimal": {}, "@coordinateFormatDecimal": {},
"unitSystemMetric": "Metric",
"@unitSystemMetric": {},
"unitSystemImperial": "Imperial",
"@unitSystemImperial": {},
"videoLoopModeNever": "Never", "videoLoopModeNever": "Never",
"@videoLoopModeNever": {}, "@videoLoopModeNever": {},
"videoLoopModeShortOnly": "Short videos only", "videoLoopModeShortOnly": "Short videos only",
@ -193,6 +202,13 @@
"mapStyleStamenWatercolor": "Stamen Watercolor", "mapStyleStamenWatercolor": "Stamen Watercolor",
"@mapStyleStamenWatercolor": {}, "@mapStyleStamenWatercolor": {},
"nameConflictStrategyRename": "Rename",
"@nameConflictStrategyRename": {},
"nameConflictStrategyReplace": "Replace",
"@nameConflictStrategyReplace": {},
"nameConflictStrategySkip": "Skip",
"@nameConflictStrategySkip": {},
"keepScreenOnNever": "Never", "keepScreenOnNever": "Never",
"@keepScreenOnNever": {}, "@keepScreenOnNever": {},
"keepScreenOnViewerOnly": "Viewer page only", "keepScreenOnViewerOnly": "Viewer page only",
@ -273,6 +289,11 @@
} }
}, },
"nameConflictDialogSingleSourceMessage": "Some files in the destination folder have the same name.",
"@nameConflictDialogSingleSourceMessage": {},
"nameConflictDialogMultipleSourceMessage": "Some files have the same name.",
"@nameConflictDialogMultipleSourceMessage": {},
"addShortcutDialogLabel": "Shortcut label", "addShortcutDialogLabel": "Shortcut label",
"@addShortcutDialogLabel": {}, "@addShortcutDialogLabel": {},
"addShortcutButtonLabel": "ADD", "addShortcutButtonLabel": "ADD",
@ -327,6 +348,9 @@
} }
}, },
"exportEntryDialogFormat": "Format:",
"@exportEntryDialogFormat": {},
"renameEntryDialogLabel": "New name", "renameEntryDialogLabel": "New name",
"@renameEntryDialogLabel": {}, "@renameEntryDialogLabel": {},
@ -555,6 +579,8 @@
"@drawerCollectionMotionPhotos": {}, "@drawerCollectionMotionPhotos": {},
"drawerCollectionPanoramas": "Panoramas", "drawerCollectionPanoramas": "Panoramas",
"@drawerCollectionPanoramas": {}, "@drawerCollectionPanoramas": {},
"drawerCollectionRaws": "Raw photos",
"@drawerCollectionRaws": {},
"drawerCollectionSphericalVideos": "360° Videos", "drawerCollectionSphericalVideos": "360° Videos",
"@drawerCollectionSphericalVideos": {}, "@drawerCollectionSphericalVideos": {},
@ -688,8 +714,8 @@
"settingsSectionViewer": "Viewer", "settingsSectionViewer": "Viewer",
"@settingsSectionViewer": {}, "@settingsSectionViewer": {},
"settingsImageBackground": "Image background", "settingsViewerShowOverlayOnOpening": "Show overlay on opening",
"@settingsImageBackground": {}, "@settingsViewerShowOverlayOnOpening": {},
"settingsViewerShowMinimap": "Show minimap", "settingsViewerShowMinimap": "Show minimap",
"@settingsViewerShowMinimap": {}, "@settingsViewerShowMinimap": {},
"settingsViewerShowInformation": "Show information", "settingsViewerShowInformation": "Show information",
@ -702,6 +728,8 @@
"@settingsViewerEnableOverlayBlurEffect": {}, "@settingsViewerEnableOverlayBlurEffect": {},
"settingsViewerUseCutout": "Use cutout area", "settingsViewerUseCutout": "Use cutout area",
"@settingsViewerUseCutout": {}, "@settingsViewerUseCutout": {},
"settingsImageBackground": "Image background",
"@settingsImageBackground": {},
"settingsViewerQuickActionsTile": "Quick actions", "settingsViewerQuickActionsTile": "Quick actions",
"@settingsViewerQuickActionsTile": {}, "@settingsViewerQuickActionsTile": {},
@ -821,6 +849,10 @@
"@settingsCoordinateFormatTile": {}, "@settingsCoordinateFormatTile": {},
"settingsCoordinateFormatTitle": "Coordinate Format", "settingsCoordinateFormatTitle": "Coordinate Format",
"@settingsCoordinateFormatTitle": {}, "@settingsCoordinateFormatTitle": {},
"settingsUnitSystemTile": "Units",
"@settingsUnitSystemTile": {},
"settingsUnitSystemTitle": "Units",
"@settingsUnitSystemTitle": {},
"statsPageTitle": "Stats", "statsPageTitle": "Stats",
"@statsPageTitle": {}, "@statsPageTitle": {},

View file

@ -27,7 +27,8 @@
"sourceStateLoading": "로딩 중", "sourceStateLoading": "로딩 중",
"sourceStateCataloguing": "분석 중", "sourceStateCataloguing": "분석 중",
"sourceStateLocating": "장소 찾는 중", "sourceStateLocatingCountries": "국가 찾는 중",
"sourceStateLocatingPlaces": "장소 찾는 중",
"chipActionDelete": "삭제", "chipActionDelete": "삭제",
"chipActionGoToAlbumPage": "앨범 페이지에서 보기", "chipActionGoToAlbumPage": "앨범 페이지에서 보기",
@ -78,6 +79,7 @@
"filterTypeAnimatedLabel": "애니메이션", "filterTypeAnimatedLabel": "애니메이션",
"filterTypeMotionPhotoLabel": "모션 포토", "filterTypeMotionPhotoLabel": "모션 포토",
"filterTypePanoramaLabel": "파노라마", "filterTypePanoramaLabel": "파노라마",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "360° 동영상", "filterTypeSphericalVideoLabel": "360° 동영상",
"filterTypeGeotiffLabel": "GeoTIFF", "filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "사진", "filterMimeImageLabel": "사진",
@ -86,6 +88,9 @@
"coordinateFormatDms": "도분초", "coordinateFormatDms": "도분초",
"coordinateFormatDecimal": "소수점", "coordinateFormatDecimal": "소수점",
"unitSystemMetric": "미터법",
"unitSystemImperial": "야드파운드법",
"videoLoopModeNever": "반복 안 함", "videoLoopModeNever": "반복 안 함",
"videoLoopModeShortOnly": "짧은 동영상만 반복", "videoLoopModeShortOnly": "짧은 동영상만 반복",
"videoLoopModeAlways": "항상 반복", "videoLoopModeAlways": "항상 반복",
@ -97,6 +102,10 @@
"mapStyleStamenToner": "Stamen 토너", "mapStyleStamenToner": "Stamen 토너",
"mapStyleStamenWatercolor": "Stamen 수채화", "mapStyleStamenWatercolor": "Stamen 수채화",
"nameConflictStrategyRename": "이름 변경",
"nameConflictStrategyReplace": "대체",
"nameConflictStrategySkip": "건너뛰기",
"keepScreenOnNever": "자동 꺼짐", "keepScreenOnNever": "자동 꺼짐",
"keepScreenOnViewerOnly": "뷰어 이용 시 작동", "keepScreenOnViewerOnly": "뷰어 이용 시 작동",
"keepScreenOnAlways": "항상 켜짐", "keepScreenOnAlways": "항상 켜짐",
@ -121,6 +130,9 @@
"notEnoughSpaceDialogTitle": "저장공간 부족", "notEnoughSpaceDialogTitle": "저장공간 부족",
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.", "notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
"nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.",
"nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.",
"addShortcutDialogLabel": "바로가기 라벨", "addShortcutDialogLabel": "바로가기 라벨",
"addShortcutButtonLabel": "추가", "addShortcutButtonLabel": "추가",
@ -146,6 +158,8 @@
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}", "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
"exportEntryDialogFormat": "형식:",
"renameEntryDialogLabel": "이름", "renameEntryDialogLabel": "이름",
"editEntryDateDialogTitle": "날짜 및 시간", "editEntryDateDialogTitle": "날짜 및 시간",
@ -256,6 +270,7 @@
"drawerCollectionVideos": "동영상", "drawerCollectionVideos": "동영상",
"drawerCollectionMotionPhotos": "모션 포토", "drawerCollectionMotionPhotos": "모션 포토",
"drawerCollectionPanoramas": "파노라마", "drawerCollectionPanoramas": "파노라마",
"drawerCollectionRaws": "Raw 이미지",
"drawerCollectionSphericalVideos": "360° 동영상", "drawerCollectionSphericalVideos": "360° 동영상",
"chipSortTitle": "정렬", "chipSortTitle": "정렬",
@ -330,13 +345,14 @@
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.", "settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
"settingsSectionViewer": "뷰어", "settingsSectionViewer": "뷰어",
"settingsImageBackground": "사진 배경", "settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시",
"settingsViewerShowMinimap": "미니맵 표시", "settingsViewerShowMinimap": "미니맵 표시",
"settingsViewerShowInformation": "상세 정보 표시", "settingsViewerShowInformation": "상세 정보 표시",
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시", "settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
"settingsViewerShowShootingDetails": "촬영 정보 표시", "settingsViewerShowShootingDetails": "촬영 정보 표시",
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과", "settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
"settingsViewerUseCutout": "컷아웃 영역 사용", "settingsViewerUseCutout": "컷아웃 영역 사용",
"settingsImageBackground": "이미지 배경",
"settingsViewerQuickActionsTile": "빠른 작업", "settingsViewerQuickActionsTile": "빠른 작업",
"settingsViewerQuickActionEditorTitle": "빠른 작업", "settingsViewerQuickActionEditorTitle": "빠른 작업",
@ -401,6 +417,8 @@
"settingsLanguage": "언어", "settingsLanguage": "언어",
"settingsCoordinateFormatTile": "좌표 표현", "settingsCoordinateFormatTile": "좌표 표현",
"settingsCoordinateFormatTitle": "좌표 표현", "settingsCoordinateFormatTitle": "좌표 표현",
"settingsUnitSystemTile": "단위법",
"settingsUnitSystemTitle": "단위법",
"statsPageTitle": "통계", "statsPageTitle": "통계",
"statsImage": "{count, plural, other{사진}}", "statsImage": "{count, plural, other{사진}}",

View file

@ -1,4 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:aves/geo/countries.dart'; import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry_cache.dart'; import 'package:aves/model/entry_cache.dart';
@ -8,7 +10,6 @@ import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/metadata/enums.dart'; import 'package:aves/model/metadata/enums.dart';
import 'package:aves/model/multipage.dart'; import 'package:aves/model/multipage.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/video/metadata.dart'; import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
@ -20,7 +21,6 @@ import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:country_code/country_code.dart'; import 'package:country_code/country_code.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class AvesEntry { class AvesEntry {
@ -76,6 +76,7 @@ class AvesEntry {
String? uri, String? uri,
String? path, String? path,
int? contentId, int? contentId,
String? title,
int? dateModifiedSecs, int? dateModifiedSecs,
List<AvesEntry>? burstEntries, List<AvesEntry>? burstEntries,
}) { }) {
@ -90,7 +91,7 @@ class AvesEntry {
height: height, height: height,
sourceRotationDegrees: sourceRotationDegrees, sourceRotationDegrees: sourceRotationDegrees,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
sourceTitle: sourceTitle, sourceTitle: title ?? sourceTitle,
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs, dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis, sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis, durationMillis: durationMillis,
@ -160,6 +161,7 @@ class AvesEntry {
String? get path => _path; String? get path => _path;
// directory path, without the trailing separator
String? get directory { String? get directory {
_directory ??= path != null ? pContext.dirname(path!) : null; _directory ??= path != null ? pContext.dirname(path!) : null;
return _directory; return _directory;
@ -170,11 +172,14 @@ class AvesEntry {
return _filename; return _filename;
} }
// file extension, including the `.`
String? get extension { String? get extension {
_extension ??= path != null ? pContext.extension(path!) : null; _extension ??= path != null ? pContext.extension(path!) : null;
return _extension; return _extension;
} }
bool get isMissingAtPath => path != null && !File(path!).existsSync();
// the MIME type reported by the Media Store is unreliable // the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible // so we use the one found during cataloguing if possible
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType; String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
@ -420,7 +425,7 @@ class AvesEntry {
_xmpSubjects = null; _xmpSubjects = null;
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
} }
void clearMetadata() { void clearMetadata() {
@ -428,17 +433,18 @@ class AvesEntry {
addressDetails = null; addressDetails = null;
} }
Future<void> catalog({bool background = false, bool persist = true, bool force = false}) async { Future<void> catalog({required bool background, required bool persist, required bool force}) async {
if (isCatalogued && !force) return; if (isCatalogued && !force) return;
if (isSvg) { if (isSvg) {
// vector image sizing is not essential, so we should not spend time for it during loading // vector image sizing is not essential, so we should not spend time for it during loading
// but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing
final size = await SvgMetadataService.getSize(this); final size = await SvgMetadataService.getSize(this);
if (size != null) { if (size != null) {
await _applyNewFields({ final fields = {
'width': size.width.ceil(), 'width': size.width.ceil(),
'height': size.height.ceil(), 'height': size.height.ceil(),
}, persist: persist); };
await _applyNewFields(fields, persist: persist);
} }
catalogMetadata = CatalogMetadata(contentId: contentId); catalogMetadata = CatalogMetadata(contentId: contentId);
} else { } else {
@ -462,17 +468,17 @@ class AvesEntry {
addressChangeNotifier.notifyListeners(); addressChangeNotifier.notifyListeners();
} }
Future<void> locate({required bool background}) async { Future<void> locate({required bool background, required bool force, required Locale geocoderLocale}) async {
if (!hasGps) return; if (!hasGps) return;
await _locateCountry(); await _locateCountry(force: force);
if (await availability.canLocatePlaces) { if (await availability.canLocatePlaces) {
await locatePlace(background: background); await locatePlace(background: background, force: force, geocoderLocale: geocoderLocale);
} }
} }
// quick reverse geocoding to find the country, using an offline asset // quick reverse geocoding to find the country, using an offline asset
Future<void> _locateCountry() async { Future<void> _locateCountry({required bool force}) async {
if (!hasGps || hasAddress) return; if (!hasGps || (hasAddress && !force)) return;
final countryCode = await countryTopology.countryCode(latLng!); final countryCode = await countryTopology.countryCode(latLng!);
setCountry(countryCode); setCountry(countryCode);
} }
@ -486,16 +492,9 @@ class AvesEntry {
); );
} }
String? _geocoderLocale;
String get geocoderLocale {
_geocoderLocale ??= (settings.locale ?? WidgetsBinding.instance!.window.locale).toString();
return _geocoderLocale!;
}
// full reverse geocoding, requiring Play Services and some connectivity // full reverse geocoding, requiring Play Services and some connectivity
Future<void> locatePlace({required bool background}) async { Future<void> locatePlace({required bool background, required bool force, required Locale geocoderLocale}) async {
if (!hasGps || hasFineAddress) return; if (!hasGps || (hasFineAddress && !force)) return;
try { try {
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale); Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
final addresses = await (background final addresses = await (background
@ -524,7 +523,7 @@ class AvesEntry {
} }
} }
Future<String?> findAddressLine() async { Future<String?> findAddressLine({required Locale geocoderLocale}) async {
if (!hasGps) return null; if (!hasGps) return null;
try { try {
@ -558,6 +557,10 @@ class AvesEntry {
}.any((s) => s != null && s.toUpperCase().contains(query)); }.any((s) => s != null && s.toUpperCase().contains(query));
Future<void> _applyNewFields(Map newFields, {required bool persist}) async { Future<void> _applyNewFields(Map newFields, {required bool persist}) async {
final oldDateModifiedSecs = this.dateModifiedSecs;
final oldRotationDegrees = this.rotationDegrees;
final oldIsFlipped = this.isFlipped;
final uri = newFields['uri']; final uri = newFields['uri'];
if (uri is String) this.uri = uri; if (uri is String) this.uri = uri;
final path = newFields['path']; final path = newFields['path'];
@ -593,10 +596,11 @@ class AvesEntry {
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!}); if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
} }
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
metadataChangeNotifier.notifyListeners(); metadataChangeNotifier.notifyListeners();
} }
Future<void> refresh({required bool persist}) async { Future<void> refresh({required bool background, required bool persist, required bool force, required Locale geocoderLocale}) async {
_catalogMetadata = null; _catalogMetadata = null;
_addressDetails = null; _addressDetails = null;
_bestDate = null; _bestDate = null;
@ -609,8 +613,8 @@ class AvesEntry {
final updated = await mediaFileService.getEntry(uri, mimeType); final updated = await mediaFileService.getEntry(uri, mimeType);
if (updated != null) { if (updated != null) {
await _applyNewFields(updated.toMap(), persist: persist); await _applyNewFields(updated.toMap(), persist: persist);
await catalog(background: false, persist: persist); await catalog(background: background, persist: persist, force: force);
await locate(background: false); await locate(background: background, force: force, geocoderLocale: geocoderLocale);
} }
} }
@ -618,11 +622,7 @@ class AvesEntry {
final newFields = await metadataEditService.rotate(this, clockwise: clockwise); final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true; return true;
} }
@ -630,11 +630,7 @@ class AvesEntry {
final newFields = await metadataEditService.flip(this); final newFields = await metadataEditService.flip(this);
if (newFields.isEmpty) return false; if (newFields.isEmpty) return false;
final oldDateModifiedSecs = dateModifiedSecs;
final oldRotationDegrees = rotationDegrees;
final oldIsFlipped = isFlipped;
await _applyNewFields(newFields, persist: persist); await _applyNewFields(newFields, persist: persist);
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
return true; return true;
} }
@ -663,7 +659,7 @@ class AvesEntry {
} }
// when the entry image itself changed (e.g. after rotation) // when the entry image itself changed (e.g. after rotation)
Future<void> _onImageChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async { Future<void> _onVisualFieldChanged(int? oldDateModifiedSecs, int oldRotationDegrees, bool oldIsFlipped) async {
if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) { if (oldDateModifiedSecs != dateModifiedSecs || oldRotationDegrees != rotationDegrees || oldIsFlipped != isFlipped) {
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped); await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
imageChangeNotifier.notifyListeners(); imageChangeNotifier.notifyListeners();

View file

@ -0,0 +1,63 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
class CoordinateFilter extends CollectionFilter {
static const type = 'coordinate';
final LatLng sw;
final LatLng ne;
final bool minuteSecondPadding;
@override
List<Object?> get props => [sw, ne];
const CoordinateFilter(this.sw, this.ne, {this.minuteSecondPadding = false});
CoordinateFilter.fromMap(Map<String, dynamic> json)
: this(
LatLng.fromJson(json['sw']),
LatLng.fromJson(json['ne']),
);
@override
Map<String, dynamic> toMap() => {
'type': type,
'sw': sw.toJson(),
'ne': ne.toJson(),
};
@override
EntryFilter get test => (entry) => GeoUtils.contains(sw, ne, entry.latLng);
String _formatBounds(CoordinateFormat format) {
String s(LatLng latLng) => format.format(
latLng,
minuteSecondPadding: minuteSecondPadding,
dmsSecondDecimals: 0,
);
return '${s(ne)}\n${s(sw)}';
}
@override
String get universalLabel => _formatBounds(CoordinateFormat.decimal);
@override
String getLabel(BuildContext context) => _formatBounds(context.read<Settings>().coordinateFormat);
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true, bool embossed = false}) => Icon(AIcons.geoBounds, size: size);
@override
String get category => type;
@override
String get key => '$type-$sw-$ne';
}

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/coordinate.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
@ -24,6 +25,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
TypeFilter.type, TypeFilter.type,
AlbumFilter.type, AlbumFilter.type,
LocationFilter.type, LocationFilter.type,
CoordinateFilter.type,
TagFilter.type, TagFilter.type,
PathFilter.type, PathFilter.type,
]; ];
@ -35,20 +37,22 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
switch (type) { switch (type) {
case AlbumFilter.type: case AlbumFilter.type:
return AlbumFilter.fromMap(jsonMap); return AlbumFilter.fromMap(jsonMap);
case CoordinateFilter.type:
return CoordinateFilter.fromMap(jsonMap);
case FavouriteFilter.type: case FavouriteFilter.type:
return FavouriteFilter.instance; return FavouriteFilter.instance;
case LocationFilter.type: case LocationFilter.type:
return LocationFilter.fromMap(jsonMap); return LocationFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case MimeFilter.type: case MimeFilter.type:
return MimeFilter.fromMap(jsonMap); return MimeFilter.fromMap(jsonMap);
case PathFilter.type:
return PathFilter.fromMap(jsonMap);
case QueryFilter.type: case QueryFilter.type:
return QueryFilter.fromMap(jsonMap); return QueryFilter.fromMap(jsonMap);
case TagFilter.type: case TagFilter.type:
return TagFilter.fromMap(jsonMap); return TagFilter.fromMap(jsonMap);
case PathFilter.type: case TypeFilter.type:
return PathFilter.fromMap(jsonMap); return TypeFilter.fromMap(jsonMap);
} }
} }
debugPrint('failed to parse filter from json=$jsonString'); debugPrint('failed to parse filter from json=$jsonString');

View file

@ -1,14 +1,19 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/common/services.dart';
class PathFilter extends CollectionFilter { class PathFilter extends CollectionFilter {
static const type = 'path'; static const type = 'path';
// including trailing separator
final String path; final String path;
// without trailing separator
final String _rootAlbum;
@override @override
List<Object?> get props => [path]; List<Object?> get props => [path];
const PathFilter(this.path); PathFilter(this.path) : _rootAlbum = path.substring(0, path.length - 1);
PathFilter.fromMap(Map<String, dynamic> json) PathFilter.fromMap(Map<String, dynamic> json)
: this( : this(
@ -22,7 +27,12 @@ class PathFilter extends CollectionFilter {
}; };
@override @override
EntryFilter get test => (entry) => entry.directory?.startsWith(path) ?? false; EntryFilter get test => (entry) {
final dir = entry.directory;
if (dir == null) return false;
// avoid string building in most cases
return dir.startsWith(_rootAlbum) && '$dir${pContext.separator}'.startsWith(path);
};
@override @override
String get universalLabel => path; String get universalLabel => path;

View file

@ -10,6 +10,7 @@ class TypeFilter extends CollectionFilter {
static const _geotiff = 'geotiff'; // subset of `image/tiff` static const _geotiff = 'geotiff'; // subset of `image/tiff`
static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg` static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg`
static const _panorama = 'panorama'; // subset of images static const _panorama = 'panorama'; // subset of images
static const _raw = 'raw'; // specific image formats
static const _sphericalVideo = 'spherical_video'; // subset of videos static const _sphericalVideo = 'spherical_video'; // subset of videos
final String itemType; final String itemType;
@ -20,6 +21,7 @@ class TypeFilter extends CollectionFilter {
static final geotiff = TypeFilter._private(_geotiff); static final geotiff = TypeFilter._private(_geotiff);
static final motionPhoto = TypeFilter._private(_motionPhoto); static final motionPhoto = TypeFilter._private(_motionPhoto);
static final panorama = TypeFilter._private(_panorama); static final panorama = TypeFilter._private(_panorama);
static final raw = TypeFilter._private(_raw);
static final sphericalVideo = TypeFilter._private(_sphericalVideo); static final sphericalVideo = TypeFilter._private(_sphericalVideo);
@override @override
@ -43,6 +45,10 @@ class TypeFilter extends CollectionFilter {
_test = (entry) => entry.isImage && entry.is360; _test = (entry) => entry.isImage && entry.is360;
_icon = AIcons.threeSixty; _icon = AIcons.threeSixty;
break; break;
case _raw:
_test = (entry) => entry.isRaw;
_icon = AIcons.raw;
break;
case _sphericalVideo: case _sphericalVideo:
_test = (entry) => entry.isVideo && entry.is360; _test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threeSixty; _icon = AIcons.threeSixty;
@ -76,6 +82,8 @@ class TypeFilter extends CollectionFilter {
return context.l10n.filterTypeMotionPhotoLabel; return context.l10n.filterTypeMotionPhotoLabel;
case _panorama: case _panorama:
return context.l10n.filterTypePanoramaLabel; return context.l10n.filterTypePanoramaLabel;
case _raw:
return context.l10n.filterTypeRawLabel;
case _sphericalVideo: case _sphericalVideo:
return context.l10n.filterTypeSphericalVideoLabel; return context.l10n.filterTypeSphericalVideoLabel;
case _geotiff: case _geotiff:

View file

@ -1,13 +1,17 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@immutable @immutable
class AddressDetails { class AddressDetails extends Equatable {
final int? contentId; final int? contentId;
final String? countryCode, countryName, adminArea, locality; final String? countryCode, countryName, adminArea, locality;
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea; String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
@override
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
const AddressDetails({ const AddressDetails({
this.contentId, this.contentId,
this.countryCode, this.countryCode,
@ -45,7 +49,4 @@ class AddressDetails {
'adminArea': adminArea, 'adminArea': adminArea,
'locality': locality, 'locality': locality,
}; };
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
} }

View file

@ -98,7 +98,6 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<void> init() async { Future<void> init() async {
debugPrint('$runtimeType init');
_database = openDatabase( _database = openDatabase(
await path, await path,
onCreate: (db, version) async { onCreate: (db, version) async {
@ -171,7 +170,6 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async { Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
if (contentIds.isEmpty) return; if (contentIds.isEmpty) return;
// final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead // using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch(); final batch = db.batch();
@ -188,7 +186,6 @@ class SqfliteMetadataDb implements MetadataDb {
} }
}); });
await batch.commit(noResult: true); await batch.commit(noResult: true);
// debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
} }
// entries // entries
@ -202,11 +199,9 @@ class SqfliteMetadataDb implements MetadataDb {
@override @override
Future<Set<AvesEntry>> loadEntries() async { Future<Set<AvesEntry>> loadEntries() async {
// final stopwatch = Stopwatch()..start();
final db = await _database; final db = await _database;
final maps = await db.query(entryTable); final maps = await db.query(entryTable);
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet(); final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
// debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
return entries; return entries;
} }

View file

@ -1,4 +1,4 @@
import 'package:aves/geo/format.dart'; import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@ -15,10 +15,10 @@ extension ExtraCoordinateFormat on CoordinateFormat {
} }
} }
String format(LatLng latLng) { String format(LatLng latLng, {bool minuteSecondPadding = false, int dmsSecondDecimals = 2}) {
switch (this) { switch (this) {
case CoordinateFormat.dms: case CoordinateFormat.dms:
return toDMS(latLng).join(', '); return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', '); return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
} }

View file

@ -13,6 +13,7 @@ import 'package:flutter/material.dart';
class SettingsDefaults { class SettingsDefaults {
// app // app
static const hasAcceptedTerms = false; static const hasAcceptedTerms = false;
static const canUseAnalysisService = true;
static const isErrorReportingEnabled = false; static const isErrorReportingEnabled = false;
static const mustBackTwiceToExit = true; static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly; static const keepScreenOn = KeepScreenOn.viewerOnly;
@ -54,6 +55,7 @@ class SettingsDefaults {
EntryAction.share, EntryAction.share,
EntryAction.rotateScreen, EntryAction.rotateScreen,
]; ];
static const showOverlayOnOpening = true;
static const showOverlayMinimap = false; static const showOverlayMinimap = false;
static const showOverlayInfo = true; static const showOverlayInfo = true;
static const showOverlayShootingDetails = false; static const showOverlayShootingDetails = false;
@ -81,6 +83,7 @@ class SettingsDefaults {
static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value
static const infoMapZoom = 12.0; static const infoMapZoom = 12.0;
static const coordinateFormat = CoordinateFormat.dms; static const coordinateFormat = CoordinateFormat.dms;
static const unitSystem = UnitSystem.metric;
// rendering // rendering
static const imageBackground = EntryBackground.white; static const imageBackground = EntryBackground.white;

View file

@ -13,4 +13,6 @@ enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenTo
enum KeepScreenOn { never, viewerOnly, always } enum KeepScreenOn { never, viewerOnly, always }
enum UnitSystem { metric, imperial }
enum VideoLoopMode { never, shortOnly, always } enum VideoLoopMode { never, shortOnly, always }

View file

@ -15,6 +15,7 @@ import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
@ -27,9 +28,7 @@ class Settings extends ChangeNotifier {
static SharedPreferences? _prefs; static SharedPreferences? _prefs;
Settings._private() { Settings._private();
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
}
static const Set<String> internalKeys = { static const Set<String> internalKeys = {
hasAcceptedTermsKey, hasAcceptedTermsKey,
@ -41,6 +40,7 @@ class Settings extends ChangeNotifier {
// app // app
static const hasAcceptedTermsKey = 'has_accepted_terms'; static const hasAcceptedTermsKey = 'has_accepted_terms';
static const canUseAnalysisServiceKey = 'can_use_analysis_service';
static const isErrorReportingEnabledKey = 'is_crashlytics_enabled'; static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
static const localeKey = 'locale'; static const localeKey = 'locale';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit'; static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
@ -73,6 +73,7 @@ class Settings extends ChangeNotifier {
// viewer // viewer
static const viewerQuickActionsKey = 'viewer_quick_actions'; static const viewerQuickActionsKey = 'viewer_quick_actions';
static const showOverlayOnOpeningKey = 'show_overlay_on_opening';
static const showOverlayMinimapKey = 'show_overlay_minimap'; static const showOverlayMinimapKey = 'show_overlay_minimap';
static const showOverlayInfoKey = 'show_overlay_info'; static const showOverlayInfoKey = 'show_overlay_info';
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details'; static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
@ -97,6 +98,7 @@ class Settings extends ChangeNotifier {
static const infoMapStyleKey = 'info_map_style'; static const infoMapStyleKey = 'info_map_style';
static const infoMapZoomKey = 'info_map_zoom'; static const infoMapZoomKey = 'info_map_zoom';
static const coordinateFormatKey = 'coordinates_format'; static const coordinateFormatKey = 'coordinates_format';
static const unitSystemKey = 'unit_system';
// rendering // rendering
static const imageBackgroundKey = 'image_background'; static const imageBackgroundKey = 'image_background';
@ -122,12 +124,16 @@ class Settings extends ChangeNotifier {
bool get initialized => _prefs != null; bool get initialized => _prefs != null;
Future<void> init({ Future<void> init({
required bool monitorPlatformSettings,
bool isRotationLocked = false, bool isRotationLocked = false,
bool areAnimationsRemoved = false, bool areAnimationsRemoved = false,
}) async { }) async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
_isRotationLocked = isRotationLocked; _isRotationLocked = isRotationLocked;
_areAnimationsRemoved = areAnimationsRemoved; _areAnimationsRemoved = areAnimationsRemoved;
if (monitorPlatformSettings) {
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
}
} }
Future<void> reset({required bool includeInternalKeys}) async { Future<void> reset({required bool includeInternalKeys}) async {
@ -141,7 +147,7 @@ class Settings extends ChangeNotifier {
Future<void> setContextualDefaults() async { Future<void> setContextualDefaults() async {
// performance // performance
final performanceClass = await deviceService.getPerformanceClass(); final performanceClass = await deviceService.getPerformanceClass();
enableOverlayBlurEffect = performanceClass >= 30; enableOverlayBlurEffect = performanceClass >= 29;
// availability // availability
final hasPlayServices = await availability.hasPlayServices; final hasPlayServices = await availability.hasPlayServices;
@ -163,6 +169,10 @@ class Settings extends ChangeNotifier {
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue); set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
bool get canUseAnalysisService => getBoolOrDefault(canUseAnalysisServiceKey, SettingsDefaults.canUseAnalysisService);
set canUseAnalysisService(bool newValue) => setAndNotify(canUseAnalysisServiceKey, newValue);
bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled); bool get isErrorReportingEnabled => getBoolOrDefault(isErrorReportingEnabledKey, SettingsDefaults.isErrorReportingEnabled);
set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue); set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue);
@ -193,6 +203,17 @@ class Settings extends ChangeNotifier {
].join(localeSeparator); ].join(localeSeparator);
} }
setAndNotify(localeKey, tag); setAndNotify(localeKey, tag);
_appliedLocale = null;
}
Locale? _appliedLocale;
Locale get appliedLocale {
if (_appliedLocale == null) {
final preferredLocale = locale;
_appliedLocale = basicLocaleListResolution(preferredLocale != null ? [preferredLocale] : null, AppLocalizations.supportedLocales);
}
return _appliedLocale!;
} }
bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, SettingsDefaults.mustBackTwiceToExit); bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, SettingsDefaults.mustBackTwiceToExit);
@ -296,6 +317,10 @@ class Settings extends ChangeNotifier {
set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList()); set viewerQuickActions(List<EntryAction> newValue) => setAndNotify(viewerQuickActionsKey, newValue.map((v) => v.toString()).toList());
bool get showOverlayOnOpening => getBoolOrDefault(showOverlayOnOpeningKey, SettingsDefaults.showOverlayOnOpening);
set showOverlayOnOpening(bool newValue) => setAndNotify(showOverlayOnOpeningKey, newValue);
bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, SettingsDefaults.showOverlayMinimap); bool get showOverlayMinimap => getBoolOrDefault(showOverlayMinimapKey, SettingsDefaults.showOverlayMinimap);
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue); set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
@ -374,6 +399,10 @@ class Settings extends ChangeNotifier {
set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString()); set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString());
UnitSystem get unitSystem => getEnumOrDefault(unitSystemKey, SettingsDefaults.unitSystem, UnitSystem.values);
set unitSystem(UnitSystem newValue) => setAndNotify(unitSystemKey, newValue.toString());
// rendering // rendering
EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values); EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values);
@ -540,6 +569,7 @@ class Settings extends ChangeNotifier {
case showThumbnailMotionPhotoKey: case showThumbnailMotionPhotoKey:
case showThumbnailRawKey: case showThumbnailRawKey:
case showThumbnailVideoDurationKey: case showThumbnailVideoDurationKey:
case showOverlayOnOpeningKey:
case showOverlayMinimapKey: case showOverlayMinimapKey:
case showOverlayInfoKey: case showOverlayInfoKey:
case showOverlayShootingDetailsKey: case showOverlayShootingDetailsKey:
@ -568,6 +598,7 @@ class Settings extends ChangeNotifier {
case subtitleTextAlignmentKey: case subtitleTextAlignmentKey:
case infoMapStyleKey: case infoMapStyleKey:
case coordinateFormatKey: case coordinateFormatKey:
case unitSystemKey:
case imageBackgroundKey: case imageBackgroundKey:
case accessibilityAnimationsKey: case accessibilityAnimationsKey:
case timeToTakeActionKey: case timeToTakeActionKey:

View file

@ -0,0 +1,15 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraUnitSystem on UnitSystem {
String getName(BuildContext context) {
switch (this) {
case UnitSystem.metric:
return context.l10n.unitSystemMetric;
case UnitSystem.imperial:
return context.l10n.unitSystemImperial;
}
}
}

View file

@ -0,0 +1,16 @@
import 'package:flutter/foundation.dart';
class AnalysisController {
final bool canStartService, force;
final List<int>? contentIds;
final ValueNotifier<bool> stopSignal;
AnalysisController({
this.canStartService = true,
this.contentIds,
this.force = false,
ValueNotifier<bool>? stopSignal,
}) : stopSignal = stopSignal ?? ValueNotifier(false);
bool get isStopping => stopSignal.value;
}

View file

@ -46,8 +46,8 @@ class CollectionLens with ChangeNotifier {
id ??= hashCode; id ??= hashCode;
if (listenToSource) { if (listenToSource) {
final sourceEvents = source.eventBus; final sourceEvents = source.eventBus;
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries))); _subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(e.entries))); _subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh())); _subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
@ -73,6 +73,20 @@ class CollectionLens with ChangeNotifier {
super.dispose(); super.dispose();
} }
CollectionLens copyWith({
CollectionSource? source,
Set<CollectionFilter>? filters,
bool? listenToSource,
List<AvesEntry>? fixedSelection,
}) =>
CollectionLens(
source: source ?? this.source,
filters: filters ?? this.filters,
id: id,
listenToSource: listenToSource ?? this.listenToSource,
fixedSelection: fixedSelection ?? this.fixedSelection,
);
bool get isEmpty => _filteredSortedEntries.isEmpty; bool get isEmpty => _filteredSortedEntries.isEmpty;
int get entryCount => _filteredSortedEntries.length; int get entryCount => _filteredSortedEntries.length;
@ -103,16 +117,16 @@ class CollectionLens with ChangeNotifier {
filters.removeWhere((old) => old.category == filter.category); filters.removeWhere((old) => old.category == filter.category);
} }
filters.add(filter); filters.add(filter);
onFilterChanged(); _onFilterChanged();
} }
void removeFilter(CollectionFilter filter) { void removeFilter(CollectionFilter filter) {
if (!filters.contains(filter)) return; if (!filters.contains(filter)) return;
filters.remove(filter); filters.remove(filter);
onFilterChanged(); _onFilterChanged();
} }
void onFilterChanged() { void _onFilterChanged() {
_refresh(); _refresh();
filterChangeNotifier.notifyListeners(); filterChangeNotifier.notifyListeners();
} }
@ -229,11 +243,11 @@ class CollectionLens with ChangeNotifier {
} }
} }
void onEntryAdded(Set<AvesEntry>? entries) { void _onEntryAdded(Set<AvesEntry>? entries) {
_refresh(); _refresh();
} }
void onEntryRemoved(Set<AvesEntry> entries) { void _onEntryRemoved(Set<AvesEntry> entries) {
if (groupBursts) { if (groupBursts) {
// find impacted burst groups // find impacted burst groups
final obsoleteBurstEntries = <AvesEntry>{}; final obsoleteBurstEntries = <AvesEntry>{};
@ -256,6 +270,7 @@ class CollectionLens with ChangeNotifier {
// we should remove obsolete entries and sections // we should remove obsolete entries and sections
// but do not apply sort/section // but do not apply sort/section
// as section order change would surprise the user while browsing // as section order change would surprise the user while browsing
fixedSelection?.removeWhere(entries.contains);
_filteredSortedEntries.removeWhere(entries.contains); _filteredSortedEntries.removeWhere(entries.contains);
_sortedEntries?.removeWhere(entries.contains); _sortedEntries?.removeWhere(entries.contains);
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains)); sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));

View file

@ -9,9 +9,11 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -29,11 +31,9 @@ mixin SourceBase {
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready); ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
final StreamController<ProgressEvent> _progressStreamController = StreamController.broadcast(); ValueNotifier<ProgressEvent> progressNotifier = ValueNotifier(const ProgressEvent(done: 0, total: 0));
Stream<ProgressEvent> get progressStream => _progressStreamController.stream; void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total);
void setProgress({required int done, required int total}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
} }
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin { abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
@ -70,9 +70,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
late Map<int?, int?> _savedDates; late Map<int?, int?> _savedDates;
Future<void> loadDates() async { Future<void> loadDates() async {
final stopwatch = Stopwatch()..start();
_savedDates = Map.unmodifiable(await metadataDb.loadDates()); _savedDates = Map.unmodifiable(await metadataDb.loadDates());
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} entries');
} }
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) { Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
@ -88,6 +86,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
invalidateTagFilterSummary(entries); invalidateTagFilterSummary(entries);
} }
void updateDerivedFilters([Set<AvesEntry>? entries]) {
_invalidate(entries);
// it is possible for entries hidden by a filter type, to have an impact on other types
// e.g. given a sole entry for country C and tag T, hiding T should make C disappear too
updateDirectories();
updateLocations();
updateTags();
}
void addEntries(Set<AvesEntry> entries) { void addEntries(Set<AvesEntry> entries) {
if (entries.isEmpty) return; if (entries.isEmpty) return;
@ -115,11 +122,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
entries.forEach((v) => _entryById.remove(v.contentId)); entries.forEach((v) => _entryById.remove(v.contentId));
_rawEntries.removeAll(entries); _rawEntries.removeAll(entries);
_invalidate(entries); updateDerivedFilters(entries);
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
updateLocations();
updateTags();
eventBus.fire(EntryRemovedEvent(entries)); eventBus.fire(EntryRemovedEvent(entries));
} }
@ -159,13 +162,34 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async { Future<bool> renameEntry(AvesEntry entry, String newName, {required bool persist}) async {
if (newName == entry.filenameWithoutExtension) return true; if (newName == entry.filenameWithoutExtension) return true;
final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}');
if (newFields.isEmpty) return false;
pauseMonitoring();
final completer = Completer<bool>();
final processed = <MoveOpEvent>{};
mediaFileService.rename({entry}, newName: '$newName${entry.extension}').listen(
processed.add,
onError: (error) => reportService.recordError('renameEntry failed with error=$error', null),
onDone: () async {
final successOps = processed.where((e) => e.success).toSet();
if (successOps.isEmpty) {
completer.complete(false);
return;
}
final newFields = successOps.first.newFields;
if (newFields.isEmpty) {
completer.complete(false);
return;
}
await _moveEntry(entry, newFields, persist: persist); await _moveEntry(entry, newFields, persist: persist);
entry.metadataChangeNotifier.notifyListeners(); entry.metadataChangeNotifier.notifyListeners();
eventBus.fire(EntryMovedEvent({entry})); eventBus.fire(EntryMovedEvent({entry}));
return true; completer.complete(true);
},
);
final success = await completer.future;
resumeMonitoring();
return success;
} }
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async { Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
@ -215,6 +239,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
uri: newFields['uri'] as String?, uri: newFields['uri'] as String?,
path: newFields['path'] as String?, path: newFields['path'] as String?,
contentId: newFields['contentId'] as int?, contentId: newFields['contentId'] as int?,
// title can change when moved files are automatically renamed to avoid conflict
title: newFields['title'] as String?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int?, dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
)); ));
} }
@ -252,18 +278,52 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
Future<void> init(); Future<void> init();
Future<void> refresh(); Future<void> refresh({AnalysisController? analysisController});
Future<void> rescan(Set<AvesEntry> entries); Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
Future<void> refreshMetadata(Set<AvesEntry> entries) async { Future<void> refreshEntry(AvesEntry entry) async {
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true)); await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
updateDerivedFilters({entry});
eventBus.fire(EntryRefreshedEvent({entry}));
}
_invalidate(entries); Future<void> analyze(AnalysisController? analysisController, {Set<AvesEntry>? entries}) async {
updateLocations(); final todoEntries = entries ?? visibleEntries;
updateTags(); final _analysisController = analysisController ?? AnalysisController();
final force = _analysisController.force;
eventBus.fire(EntryRefreshedEvent(entries)); if (!_analysisController.isStopping) {
var startAnalysisService = false;
if (_analysisController.canStartService && settings.canUseAnalysisService) {
// cataloguing
if (!startAnalysisService) {
final opCount = (force ? todoEntries : todoEntries.where(TagMixin.catalogEntriesTest)).length;
if (opCount > TagMixin.commitCountThreshold) {
startAnalysisService = true;
}
}
// ignore locating countries
// locating places
if (!startAnalysisService && await availability.canLocatePlaces) {
final opCount = (force ? todoEntries.where((entry) => entry.hasGps) : todoEntries.where(LocationMixin.locatePlacesTest)).length;
if (opCount > LocationMixin.commitCountThreshold) {
startAnalysisService = true;
}
}
}
if (startAnalysisService) {
await AnalysisService.startService(
force: force,
contentIds: entries?.map((entry) => entry.contentId).whereNotNull().toList(),
);
} else {
await catalogEntries(_analysisController, todoEntries);
updateDerivedFilters(todoEntries);
await locateEntries(_analysisController, todoEntries);
updateDerivedFilters(todoEntries);
}
}
stateNotifier.value = SourceState.ready;
} }
// monitoring // monitoring
@ -310,46 +370,45 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains); settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
} }
settings.hiddenFilters = hiddenFilters; settings.hiddenFilters = hiddenFilters;
updateDerivedFilters();
_invalidate();
// it is possible for entries hidden by a filter type, to have an impact on other types
// e.g. given a sole entry for country C and tag T, hiding T should make C disappear too
updateDirectories();
updateLocations();
updateTags();
eventBus.fire(FilterVisibilityChangedEvent(filters, visible)); eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
if (visible) { if (visible) {
refreshMetadata(visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet()); final candidateEntries = visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
analyze(null, entries: candidateEntries);
} }
} }
} }
@immutable
class EntryAddedEvent { class EntryAddedEvent {
final Set<AvesEntry>? entries; final Set<AvesEntry>? entries;
const EntryAddedEvent([this.entries]); const EntryAddedEvent([this.entries]);
} }
@immutable
class EntryRemovedEvent { class EntryRemovedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryRemovedEvent(this.entries); const EntryRemovedEvent(this.entries);
} }
@immutable
class EntryMovedEvent { class EntryMovedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryMovedEvent(this.entries); const EntryMovedEvent(this.entries);
} }
@immutable
class EntryRefreshedEvent { class EntryRefreshedEvent {
final Set<AvesEntry> entries; final Set<AvesEntry> entries;
const EntryRefreshedEvent(this.entries); const EntryRefreshedEvent(this.entries);
} }
@immutable
class FilterVisibilityChangedEvent { class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters; final Set<CollectionFilter> filters;
final bool visible; final bool visible;
@ -357,6 +416,7 @@ class FilterVisibilityChangedEvent {
const FilterVisibilityChangedEvent(this.filters, this.visible); const FilterVisibilityChangedEvent(this.filters, this.visible);
} }
@immutable
class ProgressEvent { class ProgressEvent {
final int done, total; final int done, total;

View file

@ -1,4 +1,4 @@
enum SourceState { loading, cataloguing, locating, ready } enum SourceState { loading, cataloguing, locatingCountries, locatingPlaces, ready }
enum ChipSortFactor { date, name, count } enum ChipSortFactor { date, name, count }

View file

@ -4,6 +4,8 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -12,38 +14,43 @@ import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
mixin LocationMixin on SourceBase { mixin LocationMixin on SourceBase {
static const _commitCountThreshold = 50; static const commitCountThreshold = 200;
static const _stopCheckCountThreshold = 50;
List<String> sortedCountries = List.unmodifiable([]); List<String> sortedCountries = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]); List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses() async { Future<void> loadAddresses() async {
// final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses(); final saved = await metadataDb.loadAddresses();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
Future<void> locateEntries() async { Future<void> locateEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
await _locateCountries(); await _locateCountries(controller, candidateEntries);
await _locatePlaces(); await _locatePlaces(controller, candidateEntries);
} }
static bool locateCountriesTest(AvesEntry entry) => entry.hasGps && !entry.hasAddress;
static bool locatePlacesTest(AvesEntry entry) => entry.hasGps && !entry.hasFineAddress;
// quick reverse geocoding to find the countries, using an offline asset // quick reverse geocoding to find the countries, using an offline asset
Future<void> _locateCountries() async { Future<void> _locateCountries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet(); if (controller.isStopping) return;
final force = controller.force;
final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet();
if (todo.isEmpty) return; if (todo.isEmpty) return;
stateNotifier.value = SourceState.locating; stateNotifier.value = SourceState.locatingCountries;
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
// final stopwatch = Stopwatch()..start();
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet()); final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet());
final newAddresses = <AddressDetails>[]; final newAddresses = <AddressDetails>{};
todo.forEach((entry) { todo.forEach((entry) {
final position = entry.latLng; final position = entry.latLng;
final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key; final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key;
@ -54,19 +61,18 @@ mixin LocationMixin on SourceBase {
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); });
if (newAddresses.isNotEmpty) { if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(Set.of(newAddresses)); await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
} }
// full reverse geocoding, requiring Play Services and some connectivity // full reverse geocoding, requiring Play Services and some connectivity
Future<void> _locatePlaces() async { Future<void> _locatePlaces(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
if (controller.isStopping) return;
if (!(await availability.canLocatePlaces)) return; if (!(await availability.canLocatePlaces)) return;
// final stopwatch = Stopwatch()..start(); final force = controller.force;
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress); final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet();
final todo = byLocated[false] ?? [];
if (todo.isEmpty) return; if (todo.isEmpty) return;
// geocoder calls take between 150ms and 250ms // geocoder calls take between 150ms and 250ms
@ -81,47 +87,53 @@ mixin LocationMixin on SourceBase {
final latLngFactor = pow(10, 2); final latLngFactor = pow(10, 2);
Tuple2<int, int> approximateLatLng(AvesEntry entry) { Tuple2<int, int> approximateLatLng(AvesEntry entry) {
// entry has coordinates // entry has coordinates
final lat = entry.catalogMetadata!.latitude!; final catalogMetadata = entry.catalogMetadata!;
final lng = entry.catalogMetadata!.longitude!; final lat = catalogMetadata.latitude!;
final lng = catalogMetadata.longitude!;
return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round()); return Tuple2<int, int>((lat * latLngFactor).round(), (lng * latLngFactor).round());
} }
final located = visibleEntries.where((entry) => entry.hasGps).toSet().difference(todo);
final knownLocations = <Tuple2<int, int>, AddressDetails?>{}; final knownLocations = <Tuple2<int, int>, AddressDetails?>{};
byLocated[true]?.forEach((entry) { located.forEach((entry) {
knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails); knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails);
}); });
stateNotifier.value = SourceState.locating; stateNotifier.value = SourceState.locatingPlaces;
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newAddresses = <AddressDetails>[]; var stopCheckCount = 0;
await Future.forEach<AvesEntry>(todo, (entry) async { final newAddresses = <AddressDetails>{};
for (final entry in todo) {
final latLng = approximateLatLng(entry); final latLng = approximateLatLng(entry);
if (knownLocations.containsKey(latLng)) { if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId); entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
} else { } else {
await entry.locatePlace(background: true); await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale);
// it is intended to insert `null` if the geocoder failed, // it is intended to insert `null` if the geocoder failed,
// so that we skip geocoding of following entries with the same coordinates // so that we skip geocoding of following entries with the same coordinates
knownLocations[latLng] = entry.addressDetails; knownLocations[latLng] = entry.addressDetails;
} }
if (entry.hasFineAddress) { if (entry.hasFineAddress) {
newAddresses.add(entry.addressDetails!); newAddresses.add(entry.addressDetails!);
if (newAddresses.length >= _commitCountThreshold) { if (newAddresses.length >= commitCountThreshold) {
await metadataDb.saveAddresses(Set.of(newAddresses)); await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
newAddresses.clear(); newAddresses.clear();
} }
if (++stopCheckCount >= _stopCheckCountThreshold) {
stopCheckCount = 0;
if (controller.isStopping) return;
}
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); }
if (newAddresses.isNotEmpty) { if (newAddresses.isNotEmpty) {
await metadataDb.saveAddresses(Set.of(newAddresses)); await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
onAddressMetadataChanged(); onAddressMetadataChanged();
} }
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
} }
void onAddressMetadataChanged() { void onAddressMetadataChanged() {
@ -142,9 +154,15 @@ mixin LocationMixin on SourceBase {
// so we merge countries by code, keeping only one name for each code // so we merge countries by code, keeping only one name for each code
final countriesByCode = Map.fromEntries(locations.map((address) { final countriesByCode = Map.fromEntries(locations.map((address) {
final code = address.countryCode; final code = address.countryCode;
return code?.isNotEmpty == true ? MapEntry(code, address.countryName) : null; if (code == null || code.isEmpty) return null;
return MapEntry(code, address.countryName);
}).whereNotNull()); }).whereNotNull());
final updatedCountries = countriesByCode.entries.map((kv) => '${kv.value}${LocationFilter.locationSeparator}${kv.key}').toList()..sort(compareAsciiUpperCase); final updatedCountries = countriesByCode.entries.map((kv) {
final code = kv.key;
final name = kv.value;
return '${name != null && name.isNotEmpty ? name : code}${LocationFilter.locationSeparator}$code';
}).toList()
..sort(compareAsciiUpperCase);
if (!listEquals(updatedCountries, sortedCountries)) { if (!listEquals(updatedCountries, sortedCountries)) {
sortedCountries = List.unmodifiable(updatedCountries); sortedCountries = List.unmodifiable(updatedCountries);
invalidateCountryFilterSummary(); invalidateCountryFilterSummary();

View file

@ -5,6 +5,7 @@ import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -38,11 +39,11 @@ class MediaStoreSource extends CollectionSource {
} }
await loadDates(); await loadDates();
_initialized = true; _initialized = true;
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}'); debugPrint('$runtimeType init complete in ${stopwatch.elapsed.inMilliseconds}ms');
} }
@override @override
Future<void> refresh() async { Future<void> refresh({AnalysisController? analysisController}) async {
assert(_initialized); assert(_initialized);
debugPrint('$runtimeType refresh start'); debugPrint('$runtimeType refresh start');
final stopwatch = Stopwatch()..start(); final stopwatch = Stopwatch()..start();
@ -59,10 +60,10 @@ class MediaStoreSource extends CollectionSource {
// show known entries // show known entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
addEntries(oldEntries); addEntries(oldEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
await loadCatalogMetadata(); await loadCatalogMetadata();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata');
await loadAddresses(); await loadAddresses();
updateDerivedFilters();
// clean up obsolete entries // clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
@ -110,11 +111,12 @@ class MediaStoreSource extends CollectionSource {
updateDirectories(); updateDirectories();
} }
debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries'); Set<AvesEntry>? analysisEntries;
await catalogEntries(); final analysisIds = analysisController?.contentIds;
debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries'); if (analysisIds != null) {
await locateEntries(); analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.contentId)).toSet();
stateNotifier.value = SourceState.ready; }
await analyze(analysisController, entries: analysisEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete'); debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete');
}, },
@ -127,7 +129,8 @@ class MediaStoreSource extends CollectionSource {
// 2) registered in the Media Store but still being processed by their owner in a temporary location // 2) registered in the Media Store but still being processed by their owner in a temporary location
// For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store // For example, when taking a picture with a Galaxy S10e default camera app, querying the Media Store
// sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg` // sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
Future<Set<String>> refreshUris(Set<String> changedUris) async { @override
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}) async {
if (!_initialized || !isMonitoring) return changedUris; if (!_initialized || !isMonitoring) return changedUris;
debugPrint('$runtimeType refreshUris ${changedUris.length} uris'); debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
@ -180,18 +183,10 @@ class MediaStoreSource extends CollectionSource {
addEntries(newEntries); addEntries(newEntries);
await metadataDb.saveEntries(newEntries); await metadataDb.saveEntries(newEntries);
cleanEmptyAlbums(existingDirectories); cleanEmptyAlbums(existingDirectories);
await catalogEntries();
await locateEntries(); await analyze(analysisController, entries: newEntries);
stateNotifier.value = SourceState.ready;
} }
return tempUris; return tempUris;
} }
@override
Future<void> rescan(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).whereNotNull().toSet();
await metadataDb.removeIds(contentIds, metadataOnly: true);
return refresh();
}
} }

View file

@ -0,0 +1,19 @@
import 'package:aves/model/source/enums.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
extension ExtraSourceState on SourceState {
String? getName(AppLocalizations l10n) {
switch (this) {
case SourceState.loading:
return l10n.sourceStateLoading;
case SourceState.cataloguing:
return l10n.sourceStateCataloguing;
case SourceState.locatingCountries:
return l10n.sourceStateLocatingCountries;
case SourceState.locatingPlaces:
return l10n.sourceStateLocatingPlaces;
case SourceState.ready:
return null;
}
}
}

View file

@ -1,6 +1,7 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -8,22 +9,25 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
mixin TagMixin on SourceBase { mixin TagMixin on SourceBase {
static const _commitCountThreshold = 300; static const commitCountThreshold = 400;
static const _stopCheckCountThreshold = 100;
List<String> sortedTags = List.unmodifiable([]); List<String> sortedTags = List.unmodifiable([]);
Future<void> loadCatalogMetadata() async { Future<void> loadCatalogMetadata() async {
// final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries(); final saved = await metadataDb.loadMetadataEntries();
final idMap = entryById; final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata); saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
// debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
onCatalogMetadataChanged(); onCatalogMetadataChanged();
} }
Future<void> catalogEntries() async { static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued;
// final stopwatch = Stopwatch()..start();
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList(); Future<void> catalogEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
if (controller.isStopping) return;
final force = controller.force;
final todo = force ? candidateEntries : candidateEntries.where(catalogEntriesTest).toSet();
if (todo.isEmpty) return; if (todo.isEmpty) return;
stateNotifier.value = SourceState.cataloguing; stateNotifier.value = SourceState.cataloguing;
@ -31,22 +35,26 @@ mixin TagMixin on SourceBase {
final progressTotal = todo.length; final progressTotal = todo.length;
setProgress(done: progressDone, total: progressTotal); setProgress(done: progressDone, total: progressTotal);
final newMetadata = <CatalogMetadata>[]; var stopCheckCount = 0;
await Future.forEach<AvesEntry>(todo, (entry) async { final newMetadata = <CatalogMetadata>{};
await entry.catalog(background: true); for (final entry in todo) {
await entry.catalog(background: true, persist: true, force: force);
if (entry.isCatalogued) { if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata!); newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= _commitCountThreshold) { if (newMetadata.length >= commitCountThreshold) {
await metadataDb.saveMetadata(Set.of(newMetadata)); await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
onCatalogMetadataChanged(); onCatalogMetadataChanged();
newMetadata.clear(); newMetadata.clear();
} }
if (++stopCheckCount >= _stopCheckCountThreshold) {
stopCheckCount = 0;
if (controller.isStopping) return;
}
} }
setProgress(done: ++progressDone, total: progressTotal); setProgress(done: ++progressDone, total: progressTotal);
}); }
await metadataDb.saveMetadata(Set.of(newMetadata)); await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
onCatalogMetadataChanged(); onCatalogMetadataChanged();
// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s');
} }
void onCatalogMetadataChanged() { void onCatalogMetadataChanged() {

View file

@ -46,7 +46,7 @@ class MimeTypes {
static const mov = 'video/quicktime'; static const mov = 'video/quicktime';
static const mp2t = 'video/mp2t'; // .m2ts static const mp2t = 'video/mp2t'; // .m2ts
static const mp4 = 'video/mp4'; static const mp4 = 'video/mp4';
static const ogg = 'video/ogg'; static const ogv = 'video/ogg';
static const webm = 'video/webm'; static const webm = 'video/webm';
static const json = 'application/json'; static const json = 'application/json';
@ -67,7 +67,7 @@ class MimeTypes {
static const Set<String> _knownOpaqueImages = {heic, heif, jpeg}; static const Set<String> _knownOpaqueImages = {heic, heif, jpeg};
static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogg, webm}; static const Set<String> _knownVideos = {avi, aviVnd, mkv, mov, mp2t, mp4, ogv, webm};
static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos}; static final Set<String> knownMediaTypes = {..._knownOpaqueImages, ...alphaImages, ...rawImages, ...undecodableImages, ..._knownVideos};

View file

@ -13,25 +13,36 @@ class XMP {
'crs': 'Camera Raw Settings', 'crs': 'Camera Raw Settings',
'dc': 'Dublin Core', 'dc': 'Dublin Core',
'drone-dji': 'DJI Drone', 'drone-dji': 'DJI Drone',
'exif': 'Exif',
'exifEX': 'Exif Ex', 'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images', 'GettyImagesGIFT': 'Getty Images',
'GAudio': 'Google Audio',
'GDepth': 'Google Depth',
'GImage': 'Google Image',
'GIMP': 'GIMP', 'GIMP': 'GIMP',
'GCamera': 'Google Camera', 'GCamera': 'Google Camera',
'GCreations': 'Google Creations', 'GCreations': 'Google Creations',
'GFocus': 'Google Focus', 'GFocus': 'Google Focus',
'GPano': 'Google Panorama', 'GPano': 'Google Panorama',
'illustrator': 'Illustrator', 'illustrator': 'Illustrator',
'Iptc4xmpCore': 'IPTC Core',
'Iptc4xmpExt': 'IPTC Extension',
'lr': 'Lightroom', 'lr': 'Lightroom',
'MicrosoftPhoto': 'Microsoft Photo', 'MicrosoftPhoto': 'Microsoft Photo',
'mwg-rs': 'Regions',
'panorama': 'Panorama', 'panorama': 'Panorama',
'PanoStudioXMP': 'PanoramaStudio',
'pdf': 'PDF', 'pdf': 'PDF',
'pdfx': 'PDF/X', 'pdfx': 'PDF/X',
'PanoStudioXMP': 'PanoramaStudio',
'photomechanic': 'Photo Mechanic', 'photomechanic': 'Photo Mechanic',
'photoshop': 'Photoshop',
'plus': 'PLUS', 'plus': 'PLUS',
'pmtm': 'Photomatix', 'pmtm': 'Photomatix',
'tiff': 'TIFF',
'xmp': 'Basic',
'xmpBJ': 'Basic Job Ticket', 'xmpBJ': 'Basic Job Ticket',
'xmpDM': 'Dynamic Media', 'xmpDM': 'Dynamic Media',
'xmpMM': 'Media Management',
'xmpRights': 'Rights Management', 'xmpRights': 'Rights Management',
'xmpTPg': 'Paged-Text', 'xmpTPg': 'Paged-Text',
}; };

View file

@ -0,0 +1,189 @@
import 'dart:async';
import 'dart:ui';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/model/source/source_state.dart';
import 'package:aves/services/common/services.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AnalysisService {
static const platform = MethodChannel('deckers.thibault/aves/analysis');
static Future<void> registerCallback() async {
try {
await platform.invokeMethod('registerCallback', <String, dynamic>{
'callbackHandle': PluginUtilities.getCallbackHandle(_init)?.toRawHandle(),
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
static Future<void> startService({required bool force, List<int>? contentIds}) async {
try {
await platform.invokeMethod('startService', <String, dynamic>{
'contentIds': contentIds,
'force': force,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}
const _channel = MethodChannel('deckers.thibault/aves/analysis_service_background');
Future<void> _init() async {
WidgetsFlutterBinding.ensureInitialized();
initPlatformServices();
await metadataDb.init();
await settings.init(monitorPlatformSettings: false);
FijkLog.setLevel(FijkLogLevel.Warn);
await reportService.init();
final analyzer = Analyzer();
_channel.setMethodCallHandler((call) {
switch (call.method) {
case 'start':
analyzer.start(call.arguments);
return Future.value(true);
case 'stop':
analyzer.stop();
return Future.value(true);
default:
throw PlatformException(code: 'not-implemented', message: 'failed to handle method=${call.method}');
}
});
try {
await _channel.invokeMethod('initialized');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
enum AnalyzerState { running, stopping, stopped }
class Analyzer {
late AppLocalizations _l10n;
final ValueNotifier<AnalyzerState> _serviceStateNotifier = ValueNotifier<AnalyzerState>(AnalyzerState.stopped);
AnalysisController? _controller;
Timer? _notificationUpdateTimer;
final _source = MediaStoreSource();
AnalyzerState get serviceState => _serviceStateNotifier.value;
bool get isRunning => serviceState == AnalyzerState.running;
SourceState get sourceState => _source.stateNotifier.value;
static const notificationUpdateInterval = Duration(seconds: 1);
Analyzer() {
debugPrint('$runtimeType create');
_serviceStateNotifier.addListener(_onServiceStateChanged);
_source.stateNotifier.addListener(_onSourceStateChanged);
}
void dispose() {
debugPrint('$runtimeType dispose');
_serviceStateNotifier.removeListener(_onServiceStateChanged);
_source.stateNotifier.removeListener(_onSourceStateChanged);
_stopUpdateTimer();
}
Future<void> start(dynamic args) async {
debugPrint('$runtimeType start');
List<int>? contentIds;
var force = false;
if (args is Map) {
contentIds = (args['contentIds'] as List?)?.cast<int>();
force = args['force'] ?? false;
}
_controller = AnalysisController(
canStartService: false,
contentIds: contentIds,
force: force,
stopSignal: ValueNotifier(false),
);
_l10n = await AppLocalizations.delegate.load(settings.appliedLocale);
_serviceStateNotifier.value = AnalyzerState.running;
await _source.init();
unawaited(_source.refresh(analysisController: _controller));
_notificationUpdateTimer = Timer.periodic(notificationUpdateInterval, (_) async {
if (!isRunning) return;
await _updateNotification();
});
}
void stop() {
debugPrint('$runtimeType stop');
_serviceStateNotifier.value = AnalyzerState.stopped;
}
void _stopUpdateTimer() => _notificationUpdateTimer?.cancel();
Future<void> _onServiceStateChanged() async {
switch (serviceState) {
case AnalyzerState.running:
break;
case AnalyzerState.stopping:
await _stopPlatformService();
_serviceStateNotifier.value = AnalyzerState.stopped;
break;
case AnalyzerState.stopped:
_controller?.stopSignal.value = true;
_stopUpdateTimer();
break;
}
}
void _onSourceStateChanged() {
if (sourceState == SourceState.ready) {
_refreshApp();
_serviceStateNotifier.value = AnalyzerState.stopping;
}
}
Future<void> _updateNotification() async {
if (!isRunning) return;
final title = sourceState.getName(_l10n);
if (title == null) return;
final progress = _source.progressNotifier.value;
final progressive = progress.total != 0 && sourceState != SourceState.locatingCountries;
try {
await _channel.invokeMethod('updateNotification', <String, dynamic>{
'title': title,
'message': progressive ? '${progress.done}/${progress.total}' : null,
});
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
Future<void> _refreshApp() async {
try {
await _channel.invokeMethod('refreshApp');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
Future<void> _stopPlatformService() async {
try {
await _channel.invokeMethod('stop');
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
}
}

View file

@ -10,10 +10,35 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class AndroidAppService { abstract class AndroidAppService {
Future<Set<Package>> getPackages();
Future<Uint8List> getAppIcon(String packageName, double size);
Future<bool> copyToClipboard(String uri, String? label);
Future<bool> edit(String uri, String mimeType);
Future<bool> open(String uri, String mimeType);
Future<bool> openMap(LatLng latLng);
Future<bool> setAs(String uri, String mimeType);
Future<bool> shareEntries(Iterable<AvesEntry> entries);
Future<bool> shareSingle(String uri, String mimeType);
Future<bool> canPinToHomeScreen();
Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters);
}
class PlatformAndroidAppService implements AndroidAppService {
static const platform = MethodChannel('deckers.thibault/aves/app'); static const platform = MethodChannel('deckers.thibault/aves/app');
static Future<Set<Package>> getPackages() async { @override
Future<Set<Package>> getPackages() async {
try { try {
final result = await platform.invokeMethod('getPackages'); final result = await platform.invokeMethod('getPackages');
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet(); final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
@ -29,7 +54,8 @@ class AndroidAppService {
return {}; return {};
} }
static Future<Uint8List> getAppIcon(String packageName, double size) async { @override
Future<Uint8List> getAppIcon(String packageName, double size) async {
try { try {
final result = await platform.invokeMethod('getAppIcon', <String, dynamic>{ final result = await platform.invokeMethod('getAppIcon', <String, dynamic>{
'packageName': packageName, 'packageName': packageName,
@ -42,7 +68,8 @@ class AndroidAppService {
return Uint8List(0); return Uint8List(0);
} }
static Future<bool> copyToClipboard(String uri, String? label) async { @override
Future<bool> copyToClipboard(String uri, String? label) async {
try { try {
final result = await platform.invokeMethod('copyToClipboard', <String, dynamic>{ final result = await platform.invokeMethod('copyToClipboard', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -55,7 +82,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<bool> edit(String uri, String mimeType) async { @override
Future<bool> edit(String uri, String mimeType) async {
try { try {
final result = await platform.invokeMethod('edit', <String, dynamic>{ final result = await platform.invokeMethod('edit', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -68,7 +96,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<bool> open(String uri, String mimeType) async { @override
Future<bool> open(String uri, String mimeType) async {
try { try {
final result = await platform.invokeMethod('open', <String, dynamic>{ final result = await platform.invokeMethod('open', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -81,7 +110,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<bool> openMap(LatLng latLng) async { @override
Future<bool> openMap(LatLng latLng) async {
final latitude = roundToPrecision(latLng.latitude, decimals: 6); final latitude = roundToPrecision(latLng.latitude, decimals: 6);
final longitude = roundToPrecision(latLng.longitude, decimals: 6); final longitude = roundToPrecision(latLng.longitude, decimals: 6);
final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude';
@ -97,7 +127,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<bool> setAs(String uri, String mimeType) async { @override
Future<bool> setAs(String uri, String mimeType) async {
try { try {
final result = await platform.invokeMethod('setAs', <String, dynamic>{ final result = await platform.invokeMethod('setAs', <String, dynamic>{
'uri': uri, 'uri': uri,
@ -110,7 +141,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<bool> shareEntries(Iterable<AvesEntry> entries) async { @override
Future<bool> shareEntries(Iterable<AvesEntry> entries) async {
// loosen mime type to a generic one, so we can share with badly defined apps // loosen mime type to a generic one, so we can share with badly defined apps
// e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats
final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); final urisByMimeType = groupBy<AvesEntry, String>(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()));
@ -125,7 +157,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<bool> shareSingle(String uri, String mimeType) async { @override
Future<bool> shareSingle(String uri, String mimeType) async {
try { try {
final result = await platform.invokeMethod('share', <String, dynamic>{ final result = await platform.invokeMethod('share', <String, dynamic>{
'urisByMimeType': { 'urisByMimeType': {
@ -142,9 +175,10 @@ class AndroidAppService {
// app shortcuts // app shortcuts
// this ability will not change over the lifetime of the app // this ability will not change over the lifetime of the app
static bool? _canPin; bool? _canPin;
static Future<bool> canPinToHomeScreen() async { @override
Future<bool> canPinToHomeScreen() async {
if (_canPin != null) return SynchronousFuture(_canPin!); if (_canPin != null) return SynchronousFuture(_canPin!);
try { try {
@ -159,7 +193,8 @@ class AndroidAppService {
return false; return false;
} }
static Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters) async { @override
Future<void> pinToHomeScreen(String label, AvesEntry? entry, Set<CollectionFilter> filters) async {
Uint8List? iconBytes; Uint8List? iconBytes;
if (entry != null) { if (entry != null) {
final size = entry.isVideo ? 0.0 : 256.0; final size = entry.isVideo ? 0.0 : 256.0;

View file

@ -1,5 +1,6 @@
import 'package:aves/model/availability.dart'; import 'package:aves/model/availability.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/device_service.dart'; import 'package:aves/services/device_service.dart';
import 'package:aves/services/media/embedded_data_service.dart'; import 'package:aves/services/media/embedded_data_service.dart';
import 'package:aves/services/media/media_file_service.dart'; import 'package:aves/services/media/media_file_service.dart';
@ -18,6 +19,7 @@ final p.Context pContext = getIt<p.Context>();
final AvesAvailability availability = getIt<AvesAvailability>(); final AvesAvailability availability = getIt<AvesAvailability>();
final MetadataDb metadataDb = getIt<MetadataDb>(); final MetadataDb metadataDb = getIt<MetadataDb>();
final AndroidAppService androidAppService = getIt<AndroidAppService>();
final DeviceService deviceService = getIt<DeviceService>(); final DeviceService deviceService = getIt<DeviceService>();
final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>(); final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
final MediaFileService mediaFileService = getIt<MediaFileService>(); final MediaFileService mediaFileService = getIt<MediaFileService>();
@ -33,6 +35,7 @@ void initPlatformServices() {
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability()); getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb()); getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<AndroidAppService>(() => PlatformAndroidAppService());
getIt.registerLazySingleton<DeviceService>(() => PlatformDeviceService()); getIt.registerLazySingleton<DeviceService>(() => PlatformDeviceService());
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService()); getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
getIt.registerLazySingleton<MediaFileService>(() => PlatformMediaFileService()); getIt.registerLazySingleton<MediaFileService>(() => PlatformMediaFileService());

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -9,20 +10,22 @@ class GeocodingService {
static const platform = MethodChannel('deckers.thibault/aves/geocoding'); static const platform = MethodChannel('deckers.thibault/aves/geocoding');
// geocoding requires Google Play Services // geocoding requires Google Play Services
static Future<List<Address>> getAddress(LatLng coordinates, String locale) async { static Future<List<Address>> getAddress(LatLng coordinates, Locale locale) async {
try { try {
final result = await platform.invokeMethod('getAddress', <String, dynamic>{ final result = await platform.invokeMethod('getAddress', <String, dynamic>{
'latitude': coordinates.latitude, 'latitude': coordinates.latitude,
'longitude': coordinates.longitude, 'longitude': coordinates.longitude,
'locale': locale, 'locale': locale.toString(),
// we only really need one address, but sometimes the native geocoder // we only really need one address, but sometimes the native geocoder
// returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+ // returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+
'maxResults': 2, 'maxResults': 2,
}); });
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList(); return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (e.code != 'getAddress-empty') {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return []; return [];
} }
} }

View file

@ -0,0 +1,20 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
// names should match possible values on platform
enum NameConflictStrategy { rename, replace, skip }
extension ExtraNameConflictStrategy on NameConflictStrategy {
String toPlatform() => toString().substring('NameConflictStrategy.'.length);
String getName(BuildContext context) {
switch (this) {
case NameConflictStrategy.rename:
return context.l10n.nameConflictStrategyRename;
case NameConflictStrategy.replace:
return context.l10n.nameConflictStrategyReplace;
case NameConflictStrategy.skip:
return context.l10n.nameConflictStrategySkip;
}
}
}

View file

@ -9,6 +9,7 @@ import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/output_buffer.dart'; import 'package:aves/services/common/output_buffer.dart';
import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
@ -73,12 +74,19 @@ abstract class MediaFileService {
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
required bool copy, required bool copy,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}); });
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
required String mimeType, required String mimeType,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
Stream<MoveOpEvent> rename(
Iterable<AvesEntry> entries, {
required String newName,
}); });
Future<Map<String, dynamic>> captureFrame( Future<Map<String, dynamic>> captureFrame(
@ -87,9 +95,8 @@ abstract class MediaFileService {
required Map<String, dynamic> exif, required Map<String, dynamic> exif,
required Uint8List bytes, required Uint8List bytes,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}); });
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
} }
class PlatformMediaFileService implements MediaFileService { class PlatformMediaFileService implements MediaFileService {
@ -305,6 +312,7 @@ class PlatformMediaFileService implements MediaFileService {
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
required bool copy, required bool copy,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) { }) {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
@ -312,6 +320,7 @@ class PlatformMediaFileService implements MediaFileService {
'entries': entries.map(_toPlatformEntryMap).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
'copy': copy, 'copy': copy,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
}).map((event) => MoveOpEvent.fromMap(event)); }).map((event) => MoveOpEvent.fromMap(event));
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
reportService.recordError(e, stack); reportService.recordError(e, stack);
@ -324,6 +333,7 @@ class PlatformMediaFileService implements MediaFileService {
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
required String mimeType, required String mimeType,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) { }) {
try { try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{ return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
@ -331,6 +341,7 @@ class PlatformMediaFileService implements MediaFileService {
'entries': entries.map(_toPlatformEntryMap).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': mimeType, 'mimeType': mimeType,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
}).map((event) => ExportOpEvent.fromMap(event)); }).map((event) => ExportOpEvent.fromMap(event));
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
reportService.recordError(e, stack); reportService.recordError(e, stack);
@ -338,6 +349,23 @@ class PlatformMediaFileService implements MediaFileService {
} }
} }
@override
Stream<MoveOpEvent> rename(
Iterable<AvesEntry> entries, {
required String newName,
}) {
try {
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'rename',
'entries': entries.map(_toPlatformEntryMap).toList(),
'newName': newName,
}).map((event) => MoveOpEvent.fromMap(event));
} on PlatformException catch (e, stack) {
reportService.recordError(e, stack);
return Stream.error(e);
}
}
@override @override
Future<Map<String, dynamic>> captureFrame( Future<Map<String, dynamic>> captureFrame(
AvesEntry entry, { AvesEntry entry, {
@ -345,6 +373,7 @@ class PlatformMediaFileService implements MediaFileService {
required Map<String, dynamic> exif, required Map<String, dynamic> exif,
required Uint8List bytes, required Uint8List bytes,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) async { }) async {
try { try {
final result = await platform.invokeMethod('captureFrame', <String, dynamic>{ final result = await platform.invokeMethod('captureFrame', <String, dynamic>{
@ -353,21 +382,7 @@ class PlatformMediaFileService implements MediaFileService {
'exif': exif, 'exif': exif,
'bytes': bytes, 'bytes': bytes,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
}); 'nameConflictStrategy': nameConflictStrategy.toPlatform(),
if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return {};
}
@override
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName) async {
try {
// returns map with: 'contentId' 'path' 'title' 'uri' (all optional)
final result = await platform.invokeMethod('rename', <String, dynamic>{
'entry': _toPlatformEntryMap(entry),
'newName': newName,
}); });
if (result != null) return (result as Map).cast<String, dynamic>(); if (result != null) return (result as Map).cast<String, dynamic>();
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {

View file

@ -40,8 +40,10 @@ class PlatformMetadataFetchService implements MetadataFetchService {
}); });
if (result != null) return result as Map; if (result != null) return result as Map;
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return {}; return {};
} }
@ -118,8 +120,10 @@ class PlatformMetadataFetchService implements MetadataFetchService {
} }
return MultiPageInfo.fromPageMaps(entry, pageMaps); return MultiPageInfo.fromPageMaps(entry, pageMaps);
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (!entry.isMissingAtPath) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
}
return null; return null;
} }

View file

@ -23,8 +23,15 @@ abstract class StorageService {
// returns number of deleted directories // returns number of deleted directories
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths); Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
// returns whether user granted access to volume root at `volumePath` // returns whether user granted access to a directory of his choosing
Future<bool> requestVolumeAccess(String volumePath); Future<bool> requestDirectoryAccess(String volumePath);
Future<bool> canRequestMediaFileAccess();
Future<bool> canInsertMedia(Set<VolumeRelativeDirectory> directories);
// returns whether user granted access to URIs
Future<bool> requestMediaFileAccess(List<String> uris, List<String> mimeTypes);
// return whether operation succeeded (`null` if user cancelled) // return whether operation succeeded (`null` if user cancelled)
Future<bool?> createFile(String name, String mimeType, Uint8List bytes); Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
@ -127,13 +134,37 @@ class PlatformStorageService implements StorageService {
return 0; return 0;
} }
// returns whether user granted access to volume root at `volumePath`
@override @override
Future<bool> requestVolumeAccess(String volumePath) async { Future<bool> canRequestMediaFileAccess() async {
try {
final result = await platform.invokeMethod('canRequestMediaFileBulkAccess');
if (result != null) return result as bool;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@override
Future<bool> canInsertMedia(Set<VolumeRelativeDirectory> directories) async {
try {
final result = await platform.invokeMethod('canInsertMedia', <String, dynamic>{
'directories': directories.map((v) => v.toMap()).toList(),
});
if (result != null) return result as bool;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
// returns whether user granted access to a directory of his choosing
@override
Future<bool> requestDirectoryAccess(String volumePath) async {
try { try {
final completer = Completer<bool>(); final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{ storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'requestVolumeAccess', 'op': 'requestDirectoryAccess',
'path': volumePath, 'path': volumePath,
}).listen( }).listen(
(data) => completer.complete(data as bool), (data) => completer.complete(data as bool),
@ -150,6 +181,30 @@ class PlatformStorageService implements StorageService {
return false; return false;
} }
// returns whether user granted access to URIs
@override
Future<bool> requestMediaFileAccess(List<String> uris, List<String> mimeTypes) async {
try {
final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'requestMediaFileAccess',
'uris': uris,
'mimeTypes': mimeTypes,
}).listen(
(data) => completer.complete(data as bool),
onError: completer.completeError,
onDone: () {
if (!completer.isCompleted) completer.complete(false);
},
cancelOnError: true,
);
return completer.future;
} on PlatformException catch (e, stack) {
await reportService.recordError(e, stack);
}
return false;
}
@override @override
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async { Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
try { try {

View file

@ -46,6 +46,7 @@ class AIcons {
static const IconData flip = Icons.flip_outlined; static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border; static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite; static const IconData favouriteActive = Icons.favorite;
static const IconData geoBounds = Icons.public_outlined;
static const IconData goUp = Icons.arrow_upward_outlined; static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData group = Icons.group_work_outlined; static const IconData group = Icons.group_work_outlined;
static const IconData hide = Icons.visibility_off_outlined; static const IconData hide = Icons.visibility_off_outlined;

View file

@ -1,6 +1,4 @@
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
@ -16,7 +14,7 @@ class AndroidFileUtils {
List<String> _potentialAppDirs = []; List<String> _potentialAppDirs = [];
bool _initialized = false; bool _initialized = false;
AChangeNotifier appNameChangeNotifier = AChangeNotifier(); ValueNotifier<bool> areAppNamesReadyNotifier = ValueNotifier(false);
Iterable<Package> get _launcherPackages => _packages.where((package) => package.categoryLauncher); Iterable<Package> get _launcherPackages => _packages.where((package) => package.categoryLauncher);
@ -41,9 +39,9 @@ class AndroidFileUtils {
Future<void> initAppNames() async { Future<void> initAppNames() async {
if (_packages.isEmpty) { if (_packages.isEmpty) {
_packages = await AndroidAppService.getPackages(); _packages = await androidAppService.getPackages();
_potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList(); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList();
appNameChangeNotifier.notifyListeners(); areAppNamesReadyNotifier.value = true;
} }
} }
@ -182,6 +180,11 @@ class VolumeRelativeDirectory extends Equatable {
); );
} }
Map<String, dynamic> toMap() => {
'volumePath': volumePath,
'relativeDir': relativeDir,
};
// prefer static method over a null returning factory constructor // prefer static method over a null returning factory constructor
static VolumeRelativeDirectory? fromPath(String dirPath) { static VolumeRelativeDirectory? fromPath(String dirPath) {
final volume = androidFileUtils.getStorageVolume(dirPath); final volume = androidFileUtils.getStorageVolume(dirPath);

View file

@ -1,8 +1,48 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
LatLng getLatLngCenter(List<LatLng> points) { class GeoUtils {
static String _decimal2sexagesimal(final double degDecimal, final bool minuteSecondPadding, final int secondDecimals) {
List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value
// has no decimal point (only necessary for browser)
final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
return <int>[
int.parse(tmp[0]).abs(),
int.parse(tmp[1]),
];
}
final deg = _split(degDecimal)[0];
final minDecimal = (degDecimal.abs() - deg) * 60;
final min = _split(minDecimal)[0];
final sec = (minDecimal - min) * 60;
final secRounded = roundToPrecision(sec, decimals: secondDecimals);
var minText = '$min';
var secText = secRounded.toStringAsFixed(secondDecimals);
if (minuteSecondPadding) {
minText = minText.padLeft(2, '0');
secText = secText.padLeft(secondDecimals > 0 ? 3 + secondDecimals : 2, '0');
}
return '$deg° $minText $secText';
}
// returns coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
static List<String> toDMS(LatLng latLng, {bool minuteSecondPadding = false, int secondDecimals = 2}) {
final lat = latLng.latitude;
final lng = latLng.longitude;
return [
'${_decimal2sexagesimal(lat, minuteSecondPadding, secondDecimals)} ${lat < 0 ? 'S' : 'N'}',
'${_decimal2sexagesimal(lng, minuteSecondPadding, secondDecimals)} ${lng < 0 ? 'W' : 'E'}',
];
}
static LatLng getLatLngCenter(List<LatLng> points) {
double x = 0; double x = 0;
double y = 0; double y = 0;
double z = 0; double z = 0;
@ -25,3 +65,15 @@ LatLng getLatLngCenter(List<LatLng> points) {
final lat = atan2(z, hyp); final lat = atan2(z, hyp);
return LatLng(radianToDeg(lat), radianToDeg(lng)); return LatLng(radianToDeg(lat), radianToDeg(lng));
} }
static bool contains(LatLng sw, LatLng ne, LatLng? point) {
if (point == null) return false;
final lat = point.latitude;
final lng = point.longitude;
final south = sw.latitude;
final north = ne.latitude;
final west = sw.longitude;
final east = ne.longitude;
return (south <= lat && lat <= north) && (west <= east ? (west <= lng && lng <= east) : (west <= lng || lng <= east));
}
}

View file

@ -19,6 +19,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart'; import 'package:aves/widgets/welcome_page.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:fijkplayer/fijkplayer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -46,6 +47,7 @@ class _AvesAppState extends State<AvesApp> {
List<NavigatorObserver> _navigatorObservers = []; List<NavigatorObserver> _navigatorObservers = [];
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change'); final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent'); final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
final EventChannel _analysisCompletionChannel = const EventChannel('deckers.thibault/aves/analysis_events');
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error'); final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
@ -58,6 +60,7 @@ class _AvesAppState extends State<AvesApp> {
_appSetup = _setup(); _appSetup = _setup();
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?)); _mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?)); _newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion());
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?)); _errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
} }
@ -144,9 +147,11 @@ class _AvesAppState extends State<AvesApp> {
Future<void> _setup() async { Future<void> _setup() async {
await settings.init( await settings.init(
monitorPlatformSettings: true,
isRotationLocked: await windowService.isRotationLocked(), isRotationLocked: await windowService.isRotationLocked(),
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(), areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
); );
FijkLog.setLevel(FijkLogLevel.Warn);
// keep screen on // keep screen on
settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen( settings.updateStream.where((key) => key == Settings.keepScreenOnKey).listen(
@ -192,6 +197,13 @@ class _AvesAppState extends State<AvesApp> {
)); ));
} }
Future<void> _onAnalysisCompletion() async {
debugPrint('Analysis completed');
await _mediaStoreSource.loadCatalogMetadata();
await _mediaStoreSource.loadAddresses();
_mediaStoreSource.updateDerivedFilters();
}
void _onMediaStoreChange(String? uri) { void _onMediaStoreChange(String? uri) {
if (uri != null) changedUris.add(uri); if (uri != null) changedUris.add(uri);
if (changedUris.isNotEmpty) { if (changedUris.isNotEmpty) {

View file

@ -9,7 +9,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
import 'package:aves/widgets/collection/filter_bar.dart'; import 'package:aves/widgets/collection/filter_bar.dart';
@ -61,7 +61,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
vsync: this, vsync: this,
); );
_isSelectingNotifier.addListener(_onActivityChange); _isSelectingNotifier.addListener(_onActivityChange);
_canAddShortcutsLoader = AndroidAppService.canPinToHomeScreen(); _canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
_registerWidget(widget); _registerWidget(widget);
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged()); WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
} }
@ -200,17 +200,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection); final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
return [ return [
_toMenuItem( _toMenuItem(EntrySetAction.sort),
EntrySetAction.sort, if (groupable) _toMenuItem(EntrySetAction.group),
// key is expected by test driver
key: const Key('menu-sort'),
),
if (groupable)
_toMenuItem(
EntrySetAction.group,
// key is expected by test driver
key: const Key('menu-group'),
),
if (appMode == AppMode.main) ...[ if (appMode == AppMode.main) ...[
if (!isSelecting) if (!isSelecting)
_toMenuItem( _toMenuItem(
@ -254,9 +245,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
]; ];
} }
PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {Key? key, bool enabled = true}) { PopupMenuItem<EntrySetAction> _toMenuItem(EntrySetAction action, {bool enabled = true}) {
return PopupMenuItem( return PopupMenuItem(
key: key, // key is expected by test driver (e.g. 'menu-sort', 'menu-group', 'menu-map')
key: Key('menu-${action.toString().substring('EntrySetAction.'.length)}'),
value: action, value: action,
enabled: enabled, enabled: enabled,
child: MenuRow(text: action.getText(context), icon: action.getIcon()), child: MenuRow(text: action.getText(context), icon: action.getIcon()),
@ -356,7 +348,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
// we compute the default name beforehand // we compute the default name beforehand
// because some filter labels need localization // because some filter labels need localization
final sortedFilters = List<CollectionFilter>.from(filters)..sort(); final sortedFilters = List<CollectionFilter>.from(filters)..sort();
defaultName = sortedFilters.first.getLabel(context); defaultName = sortedFilters.first.getLabel(context).replaceAll('\n', ' ');
} }
final result = await showDialog<Tuple2<AvesEntry?, String>>( final result = await showDialog<Tuple2<AvesEntry?, String>>(
context: context, context: context,
@ -371,7 +363,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
final name = result.item2; final name = result.item2;
if (name.isEmpty) return; if (name.isEmpty) return;
unawaited(AndroidAppService.pinToHomeScreen(name, coverEntry, filters)); unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters));
} }
void _goToSearch() { void _goToSearch() {

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:aves/model/actions/entry_set_actions.dart'; import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
@ -6,19 +7,20 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/model/selection.dart'; import 'package:aves/model/selection.dart';
import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/map/map_page.dart'; import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/stats/stats_page.dart'; import 'package:aves/widgets/stats/stats_page.dart';
@ -63,7 +65,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
void _share(BuildContext context) { void _share(BuildContext context) {
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);
AndroidAppService.shareEntries(selectedItems).then((success) { androidAppService.shareEntries(selectedItems).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
} }
@ -73,29 +75,18 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);
source.rescan(selectedItems); final controller = AnalysisController(canStartService: true, force: true);
source.analyze(controller, entries: selectedItems);
selection.browse(); selection.browse();
} }
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async { Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
final l10n = context.l10n;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
final selection = context.read<Selection<AvesEntry>>(); final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection); final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
if (moveType == MoveType.move) {
// check whether moving is possible given OS restrictions,
// before asking to pick a destination album
final restrictedDirs = await storageService.getRestrictedDirectories();
for (final selectionDir in selectionDirs) {
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
if (dir == null) return;
if (restrictedDirs.contains(dir)) {
await showRestrictedDirectoryDialog(context, dir);
return;
}
}
}
final destinationAlbum = await Navigator.push( final destinationAlbum = await Navigator.push(
context, context,
@ -107,7 +98,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (destinationAlbum == null || destinationAlbum.isEmpty) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return; if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return;
@ -119,13 +110,44 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final todoCount = todoEntries.length; final todoCount = todoEntries.length;
assert(todoCount > 0); assert(todoCount > 0);
final destinationDirectory = Directory(destinationAlbum);
final names = [
...todoEntries.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
// do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
];
final uniqueNames = names.toSet();
var nameConflictStrategy = NameConflictStrategy.rename;
if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>(
context: context,
builder: (context) {
return AvesSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
);
},
);
if (value == null) return;
nameConflictStrategy = value;
}
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<MoveOpEvent>( showOpReport<MoveOpEvent>(
context: context, context: context,
opStream: mediaFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum), opStream: mediaFileService.move(
todoEntries,
copy: copy,
destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy,
),
itemCount: todoCount, itemCount: todoCount,
onDone: (processed) async { onDone: (processed) async {
final movedOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.newFields.containsKey('skipped')).toSet();
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: todoEntries, todoEntries: todoEntries,
copy: copy, copy: copy,
@ -140,18 +162,18 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
await storageService.deleteEmptyDirectories(selectionDirs); await storageService.deleteEmptyDirectories(selectionDirs);
} }
final l10n = context.l10n; final successCount = successOps.length;
final movedCount = movedOps.length; if (successCount < todoCount) {
if (movedCount < todoCount) { final count = todoCount - successCount;
final count = todoCount - movedCount;
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
} else { } else {
final count = movedCount; final count = movedOps.length;
showFeedback( showFeedback(
context, context,
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count), copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
SnackBarAction( count > 0
label: context.l10n.showButtonLabel, ? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () async { onPressed: () async {
final highlightInfo = context.read<HighlightInfo>(); final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
@ -183,7 +205,8 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
} }
}, },
), )
: null,
); );
} }
}, },
@ -218,7 +241,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
); );
if (confirmed == null || !confirmed) return; if (confirmed == null || !confirmed) return;
if (!await checkStoragePermissionForAlbums(context, selectionDirs)) return; if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
source.pauseMonitoring(); source.pauseMonitoring();
showOpReport<ImageOpEvent>( showOpReport<ImageOpEvent>(
@ -253,6 +276,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: MapPage.routeName), settings: const RouteSettings(name: MapPage.routeName),
builder: (context) => MapPage( builder: (context) => MapPage(
// need collection with fresh ID to prevent hero from scroller on Map page to Collection page
collection: CollectionLens( collection: CollectionLens(
source: collection.source, source: collection.source,
filters: collection.filters, filters: collection.filters,

View file

@ -73,10 +73,7 @@ class InteractiveThumbnail extends StatelessWidget {
TransparentMaterialPageRoute( TransparentMaterialPageRoute(
settings: const RouteSettings(name: EntryViewerPage.routeName), settings: const RouteSettings(name: EntryViewerPage.routeName),
pageBuilder: (context, a, sa) { pageBuilder: (context, a, sa) {
final viewerCollection = CollectionLens( final viewerCollection = collection.copyWith(
source: collection.source,
filters: collection.filters,
id: collection.id,
listenToSource: false, listenToSource: false,
); );
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId)); assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));

View file

@ -8,20 +8,40 @@ import 'package:flutter/material.dart';
mixin PermissionAwareMixin { mixin PermissionAwareMixin {
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) { Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) {
return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet()); return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet(), entries: entries);
} }
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async { Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths, {Set<AvesEntry>? entries}) async {
final restrictedDirs = await storageService.getRestrictedDirectories(); final restrictedDirs = await storageService.getRestrictedDirectories();
while (true) { while (true) {
final dirs = await storageService.getInaccessibleDirectories(albumPaths); final dirs = await storageService.getInaccessibleDirectories(albumPaths);
if (dirs.isEmpty) return true;
final restrictedInaccessibleDir = dirs.firstWhereOrNull(restrictedDirs.contains); final restrictedInaccessibleDirs = dirs.where(restrictedDirs.contains).toSet();
if (restrictedInaccessibleDir != null) { if (restrictedInaccessibleDirs.isNotEmpty) {
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir); if (entries != null && await storageService.canRequestMediaFileAccess()) {
// request media file access for items in restricted directories
final uris = <String>[], mimeTypes = <String>[];
entries.where((entry) {
final dir = entry.directory;
return dir != null && restrictedInaccessibleDirs.contains(VolumeRelativeDirectory.fromPath(dir));
}).forEach((entry) {
uris.add(entry.uri);
mimeTypes.add(entry.mimeType);
});
final granted = await storageService.requestMediaFileAccess(uris, mimeTypes);
if (!granted) return false;
} else if (entries == null && await storageService.canInsertMedia(restrictedInaccessibleDirs)) {
// insertion in restricted directories
} else {
// cannot proceed further
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDirs.first);
return false; return false;
} }
// clear restricted directories
dirs.removeAll(restrictedInaccessibleDirs);
}
if (dirs.isEmpty) return true;
final dir = dirs.first; final dir = dirs.first;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@ -49,7 +69,7 @@ mixin PermissionAwareMixin {
// abort if the user cancels in Flutter // abort if the user cancels in Flutter
if (confirmed == null || !confirmed) return false; if (confirmed == null || !confirmed) return false;
final granted = await storageService.requestVolumeAccess(dir.volumePath); final granted = await storageService.requestDirectoryAccess(dir.volumePath);
if (!granted) { if (!granted) {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog
return false; return false;

View file

@ -1,5 +1,6 @@
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/source_state.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -56,38 +57,24 @@ class SourceStateSubtitle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
String? subtitle; final sourceState = source.stateNotifier.value;
switch (source.stateNotifier.value) { final subtitle = sourceState.getName(context.l10n);
case SourceState.loading: if (subtitle == null) return const SizedBox();
subtitle = context.l10n.sourceStateLoading;
break; final subtitleStyle = Theme.of(context).textTheme.caption!;
case SourceState.cataloguing: return Row(
subtitle = context.l10n.sourceStateCataloguing;
break;
case SourceState.locating:
subtitle = context.l10n.sourceStateLocating;
break;
case SourceState.ready:
default:
break;
}
final subtitleStyle = Theme.of(context).textTheme.caption;
return subtitle == null
? const SizedBox.shrink()
: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(subtitle, style: subtitleStyle), Text(subtitle, style: subtitleStyle),
StreamBuilder<ProgressEvent>( ValueListenableBuilder<ProgressEvent>(
stream: source.progressStream, valueListenable: source.progressNotifier,
builder: (context, snapshot) { builder: (context, progress, snapshot) {
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink(); if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox();
final progress = snapshot.data!;
return Padding( return Padding(
padding: const EdgeInsetsDirectional.only(start: 8), padding: const EdgeInsetsDirectional.only(start: 8),
child: Text( child: Text(
'${progress.done}/${progress.total}', '${progress.done}/${progress.total}',
style: subtitleStyle!.copyWith(color: Colors.white30), style: subtitleStyle.copyWith(color: Colors.white30),
), ),
); );
}, },

View file

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -13,17 +11,48 @@ class BottomGestureAreaProtector extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Selector<MediaQueryData, double>(
selector: (context, mq) => mq.systemGestureInsets.bottom,
builder: (context, systemGestureBottom, child) {
return Positioned( return Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
height: systemGestureBottom, height: context.select<MediaQueryData, double>((mq) => mq.systemGestureInsets.bottom),
child: const AbsorbPointer(), child: GestureDetector(
// absorb vertical gestures only
onVerticalDragDown: (details) {},
behavior: HitTestBehavior.translucent,
),
); );
}, }
}
// It will prevent the body from scrolling when a user swipe from edges to use Android Q style navigation gestures.
class SideGestureAreaProtector extends StatelessWidget {
const SideGestureAreaProtector({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: Row(
children: [
SizedBox(
width: context.select<MediaQueryData, double>((mq) => mq.systemGestureInsets.left),
child: GestureDetector(
// absorb horizontal gestures only
onHorizontalDragDown: (details) {},
behavior: HitTestBehavior.translucent,
),
),
const Spacer(),
SizedBox(
width: context.select<MediaQueryData, double>((mq) => mq.systemGestureInsets.right),
child: GestureDetector(
// absorb horizontal gestures only
onHorizontalDragDown: (details) {},
behavior: HitTestBehavior.translucent,
),
),
],
),
); );
} }
} }
@ -54,7 +83,7 @@ class BottomPaddingSliver extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom), selector: (context, mq) => mq.effectiveBottomPadding,
builder: (context, mqPaddingBottom, child) { builder: (context, mqPaddingBottom, child) {
return SizedBox(height: mqPaddingBottom); return SizedBox(height: mqPaddingBottom);
}, },

View file

@ -13,6 +13,7 @@ class TransitionImage extends StatefulWidget {
final double? width, height; final double? width, height;
final ValueListenable<double> animation; final ValueListenable<double> animation;
final bool gaplessPlayback = false; final bool gaplessPlayback = false;
final Color? background;
const TransitionImage({ const TransitionImage({
Key? key, Key? key,
@ -20,6 +21,7 @@ class TransitionImage extends StatefulWidget {
required this.animation, required this.animation,
this.width, this.width,
this.height, this.height,
this.background,
}) : super(key: key); }) : super(key: key);
@override @override
@ -136,10 +138,10 @@ class _TransitionImageState extends State<TransitionImage> {
valueListenable: widget.animation, valueListenable: widget.animation,
builder: (context, t, child) => CustomPaint( builder: (context, t, child) => CustomPaint(
painter: _TransitionImagePainter( painter: _TransitionImagePainter(
// AssetImage(name).resolve(configuration)
image: _imageInfo?.image, image: _imageInfo?.image,
scale: _imageInfo?.scale ?? 1.0, scale: _imageInfo?.scale ?? 1.0,
t: t, t: t,
background: widget.background,
), ),
), ),
); );
@ -150,11 +152,13 @@ class _TransitionImagePainter extends CustomPainter {
final ui.Image? image; final ui.Image? image;
final double scale; final double scale;
final double t; final double t;
final Color? background;
const _TransitionImagePainter({ const _TransitionImagePainter({
required this.image, required this.image,
required this.scale, required this.scale,
required this.t, required this.t,
this.background,
}); });
@override @override
@ -185,6 +189,9 @@ class _TransitionImagePainter extends CustomPainter {
sourceSize, sourceSize,
Offset.zero & inputSize, Offset.zero & inputSize,
); );
if (background != null) {
canvas.drawRect(destinationRect, Paint()..color = background!);
}
canvas.drawImageRect(image!, sourceRect, destinationRect, paint); canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
} }

View file

@ -37,12 +37,11 @@ class AvesFilterDecoration {
class AvesFilterChip extends StatefulWidget { class AvesFilterChip extends StatefulWidget {
final CollectionFilter filter; final CollectionFilter filter;
final bool removable; final bool removable, showGenericIcon, useFilterColor;
final bool showGenericIcon;
final AvesFilterDecoration? decoration; final AvesFilterDecoration? decoration;
final String? banner; final String? banner;
final Widget? details; final Widget? details;
final double padding; final double padding, maxWidth;
final HeroType heroType; final HeroType heroType;
final FilterCallback? onTap; final FilterCallback? onTap;
final OffsetFilterCallback? onLongPress; final OffsetFilterCallback? onLongPress;
@ -52,7 +51,7 @@ class AvesFilterChip extends StatefulWidget {
static const double outlineWidth = 2; static const double outlineWidth = 2;
static const double minChipHeight = kMinInteractiveDimension; static const double minChipHeight = kMinInteractiveDimension;
static const double minChipWidth = 80; static const double minChipWidth = 80;
static const double maxChipWidth = 160; static const double defaultMaxChipWidth = 160;
static const double iconSize = 18; static const double iconSize = 18;
static const double fontSize = 14; static const double fontSize = 14;
static const double decoratedContentVerticalPadding = 5; static const double decoratedContentVerticalPadding = 5;
@ -62,10 +61,12 @@ class AvesFilterChip extends StatefulWidget {
required this.filter, required this.filter,
this.removable = false, this.removable = false,
this.showGenericIcon = true, this.showGenericIcon = true,
this.useFilterColor = true,
this.decoration, this.decoration,
this.banner, this.banner,
this.details, this.details,
this.padding = 6.0, this.padding = 6.0,
this.maxWidth = defaultMaxChipWidth,
this.heroType = HeroType.onTap, this.heroType = HeroType.onTap,
this.onTap, this.onTap,
this.onLongPress = showDefaultLongPressMenu, this.onLongPress = showDefaultLongPressMenu,
@ -181,7 +182,6 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
), ),
softWrap: false, softWrap: false,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
maxLines: 1,
), ),
), ),
if (trailing != null) ...[ if (trailing != null) ...[
@ -216,7 +216,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
); );
} else { } else {
content = Padding( content = Padding(
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2), padding: EdgeInsets.symmetric(horizontal: padding * 2),
child: content, child: content,
); );
} }
@ -224,9 +224,9 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius)); final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
final banner = widget.banner; final banner = widget.banner;
Widget chip = Container( Widget chip = Container(
constraints: const BoxConstraints( constraints: BoxConstraints(
minWidth: AvesFilterChip.minChipWidth, minWidth: AvesFilterChip.minChipWidth,
maxWidth: AvesFilterChip.maxChipWidth, maxWidth: widget.maxWidth,
minHeight: AvesFilterChip.minChipHeight, minHeight: AvesFilterChip.minChipHeight,
), ),
child: Stack( child: Stack(
@ -263,16 +263,13 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.fromBorderSide(BorderSide( border: Border.fromBorderSide(BorderSide(
color: _outlineColor, color: widget.useFilterColor ? _outlineColor : AvesFilterChip.defaultOutlineColor,
width: AvesFilterChip.outlineWidth, width: AvesFilterChip.outlineWidth,
)), )),
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
position: DecorationPosition.foreground, position: DecorationPosition.foreground,
child: Padding(
padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8),
child: content, child: content,
),
); );
}, },
), ),

View file

@ -1,16 +1,20 @@
import 'package:aves/model/filters/coordinate.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/common/fx/borders.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/common/map/compass.dart'; import 'package:aves/widgets/common/map/compass.dart';
import 'package:aves/widgets/common/map/theme.dart'; import 'package:aves/widgets/common/map/theme.dart';
import 'package:aves/widgets/common/map/zoomed_bounds.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/viewer/info/notifications.dart';
import 'package:aves/widgets/viewer/overlay/common.dart'; import 'package:aves/widgets/viewer/overlay/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -58,14 +62,11 @@ class MapButtonPanel extends StatelessWidget {
break; break;
} }
final showCoordinateFilter = context.select<MapThemeData, bool>((v) => v.showCoordinateFilter);
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity); final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
final double padding = visualDensity == VisualDensity.compact ? 4 : 8; final double padding = visualDensity == VisualDensity.compact ? 4 : 8;
return Positioned.fill( return Positioned.fill(
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: Padding(
padding: EdgeInsets.all(padding),
child: TooltipTheme( child: TooltipTheme(
data: TooltipTheme.of(context).copyWith( data: TooltipTheme.of(context).copyWith(
preferBelow: false, preferBelow: false,
@ -75,7 +76,13 @@ class MapButtonPanel extends StatelessWidget {
child: Stack( child: Stack(
children: [ children: [
Positioned( Positioned(
left: 0, left: padding,
right: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(top: padding),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -115,12 +122,17 @@ class MapButtonPanel extends StatelessWidget {
], ],
), ),
), ),
Positioned( showCoordinateFilter
right: 0, ? Expanded(
child: Column( child: _OverlayCoordinateFilterChip(
mainAxisSize: MainAxisSize.min, boundsNotifier: boundsNotifier,
children: [ padding: padding,
MapOverlayButton( ),
)
: const Spacer(),
Padding(
padding: EdgeInsets.only(top: padding),
child: MapOverlayButton(
icon: const Icon(AIcons.layers), icon: const Icon(AIcons.layers),
onPressed: () async { onPressed: () async {
final hasPlayServices = await availability.hasPlayServices; final hasPlayServices = await availability.hasPlayServices;
@ -145,12 +157,13 @@ class MapButtonPanel extends StatelessWidget {
}, },
tooltip: context.l10n.mapStyleTooltip, tooltip: context.l10n.mapStyleTooltip,
), ),
),
], ],
), ),
), ),
Positioned( Positioned(
right: 0, right: padding,
bottom: 0, bottom: padding,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -172,8 +185,6 @@ class MapButtonPanel extends StatelessWidget {
), ),
), ),
), ),
),
),
); );
} }
} }
@ -225,3 +236,102 @@ class MapOverlayButton extends StatelessWidget {
); );
} }
} }
class _OverlayCoordinateFilterChip extends StatefulWidget {
final ValueNotifier<ZoomedBounds> boundsNotifier;
final double padding;
const _OverlayCoordinateFilterChip({
Key? key,
required this.boundsNotifier,
required this.padding,
}) : super(key: key);
@override
_OverlayCoordinateFilterChipState createState() => _OverlayCoordinateFilterChipState();
}
class _OverlayCoordinateFilterChipState extends State<_OverlayCoordinateFilterChip> {
final Debouncer _debouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
final ValueNotifier<ZoomedBounds?> _idleBoundsNotifier = ValueNotifier(null);
@override
void initState() {
super.initState();
_registerWidget(widget);
}
@override
void didUpdateWidget(covariant _OverlayCoordinateFilterChip oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(_OverlayCoordinateFilterChip widget) {
widget.boundsNotifier.addListener(_onBoundsChanged);
}
void _unregisterWidget(_OverlayCoordinateFilterChip widget) {
widget.boundsNotifier.removeListener(_onBoundsChanged);
}
@override
Widget build(BuildContext context) {
final blurred = settings.enableOverlayBlurEffect;
return Theme(
data: Theme.of(context).copyWith(
scaffoldBackgroundColor: overlayBackgroundColor(blurred: blurred),
),
child: Align(
alignment: Alignment.topLeft,
child: Selector<MapThemeData, Animation<double>>(
selector: (context, v) => v.scale,
builder: (context, scale, child) => SizeTransition(
sizeFactor: scale,
axisAlignment: 1,
child: FadeTransition(
opacity: scale,
child: child,
),
),
child: ValueListenableBuilder<ZoomedBounds?>(
valueListenable: _idleBoundsNotifier,
builder: (context, bounds, child) {
if (bounds == null) return const SizedBox();
final filter = CoordinateFilter(
bounds.sw,
bounds.ne,
// more stable format when bounds change
minuteSecondPadding: true,
);
return Padding(
padding: EdgeInsets.all(widget.padding),
child: BlurredRRect(
enabled: blurred,
borderRadius: AvesFilterChip.defaultRadius,
child: AvesFilterChip(
filter: filter,
useFilterColor: false,
maxWidth: double.infinity,
onTap: (filter) => FilterSelectedNotification(CoordinateFilter(bounds.sw, bounds.ne) ).dispatch(context),
),
),
);
},
),
),
),
);
}
void _onBoundsChanged() {
_debouncer(() => _idleBoundsNotifier.value = widget.boundsNotifier.value);
}
}

View file

@ -6,6 +6,7 @@ import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/math_utils.dart';
import 'package:aves/widgets/common/map/attribution.dart'; import 'package:aves/widgets/common/map/attribution.dart';
@ -27,6 +28,7 @@ import 'package:provider/provider.dart';
class GeoMap extends StatefulWidget { class GeoMap extends StatefulWidget {
final AvesMapController? controller; final AvesMapController? controller;
final Listenable? collectionListenable;
final List<AvesEntry> entries; final List<AvesEntry> entries;
final AvesEntry? initialEntry; final AvesEntry? initialEntry;
final ValueNotifier<bool> isAnimatingNotifier; final ValueNotifier<bool> isAnimatingNotifier;
@ -42,6 +44,7 @@ class GeoMap extends StatefulWidget {
const GeoMap({ const GeoMap({
Key? key, Key? key,
this.controller, this.controller,
this.collectionListenable,
required this.entries, required this.entries,
this.initialEntry, this.initialEntry,
required this.isAnimatingNotifier, required this.isAnimatingNotifier,
@ -57,27 +60,57 @@ class GeoMap extends StatefulWidget {
} }
class _GeoMapState extends State<GeoMap> { class _GeoMapState extends State<GeoMap> {
// as of google_maps_flutter v2.0.6, Google Maps initialization is blocking // as of google_maps_flutter v2.0.6, Google map initialization is blocking
// cf https://github.com/flutter/flutter/issues/28493 // cf https://github.com/flutter/flutter/issues/28493
// it is especially severe the first time, but still significant afterwards // it is especially severe the first time, but still significant afterwards
// so we prevent loading it while scrolling or animating // so we prevent loading it while scrolling or animating
bool _googleMapsLoaded = false; bool _googleMapsLoaded = false;
late final ValueNotifier<ZoomedBounds> _boundsNotifier; late final ValueNotifier<ZoomedBounds> _boundsNotifier;
late final Fluster<GeoEntry> _defaultMarkerCluster; Fluster<GeoEntry>? _defaultMarkerCluster;
Fluster<GeoEntry>? _slowMarkerCluster; Fluster<GeoEntry>? _slowMarkerCluster;
final AChangeNotifier _clusterChangeNotifier = AChangeNotifier();
List<AvesEntry> get entries => widget.entries; List<AvesEntry> get entries => widget.entries;
// cap initial zoom to avoid a zoom change
// when toggling overlay on Google map initial state
static const double minInitialZoom = 3;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final initialEntry = widget.initialEntry; final initialEntry = widget.initialEntry;
final points = (initialEntry != null ? [initialEntry] : entries).map((v) => v.latLng!).toSet(); final points = (initialEntry != null ? [initialEntry] : entries).map((v) => v.latLng!).toSet();
_boundsNotifier = ValueNotifier(ZoomedBounds.fromPoints( final bounds = ZoomedBounds.fromPoints(
points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]}, points: points.isNotEmpty ? points : {Constants.wonders[Random().nextInt(Constants.wonders.length)]},
collocationZoom: settings.infoMapZoom, collocationZoom: settings.infoMapZoom,
);
_boundsNotifier = ValueNotifier(bounds.copyWith(
zoom: max(bounds.zoom, minInitialZoom),
)); ));
_defaultMarkerCluster = _buildFluster(); _registerWidget(widget);
_onCollectionChanged();
}
@override
void didUpdateWidget(covariant GeoMap oldWidget) {
super.didUpdateWidget(oldWidget);
_unregisterWidget(oldWidget);
_registerWidget(widget);
}
@override
void dispose() {
_unregisterWidget(widget);
super.dispose();
}
void _registerWidget(GeoMap widget) {
widget.collectionListenable?.addListener(_onCollectionChanged);
}
void _unregisterWidget(GeoMap widget) {
widget.collectionListenable?.removeListener(_onCollectionChanged);
} }
@override @override
@ -92,11 +125,11 @@ class _GeoMapState extends State<GeoMap> {
return {geoEntry.entry!}; return {geoEntry.entry!};
} }
var points = _defaultMarkerCluster.points(clusterId); var points = _defaultMarkerCluster?.points(clusterId) ?? [];
if (points.length != geoEntry.pointsSize) { if (points.length != geoEntry.pointsSize) {
// `Fluster.points()` method does not always return all the points contained in a cluster // `Fluster.points()` method does not always return all the points contained in a cluster
// the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`)
_slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length)); _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length));
points = _slowMarkerCluster!.points(clusterId); points = _slowMarkerCluster!.points(clusterId);
assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry');
} }
@ -137,6 +170,7 @@ class _GeoMapState extends State<GeoMap> {
Widget child = isGoogleMaps Widget child = isGoogleMaps
? EntryGoogleMap( ? EntryGoogleMap(
controller: widget.controller, controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier, boundsNotifier: _boundsNotifier,
minZoom: 0, minZoom: 0,
maxZoom: 20, maxZoom: 20,
@ -151,6 +185,7 @@ class _GeoMapState extends State<GeoMap> {
) )
: EntryLeafletMap( : EntryLeafletMap(
controller: widget.controller, controller: widget.controller,
clusterListenable: _clusterChangeNotifier,
boundsNotifier: _boundsNotifier, boundsNotifier: _boundsNotifier,
minZoom: 2, minZoom: 2,
maxZoom: 16, maxZoom: 16,
@ -230,6 +265,12 @@ class _GeoMapState extends State<GeoMap> {
); );
} }
void _onCollectionChanged() {
_defaultMarkerCluster = _buildFluster();
_slowMarkerCluster = null;
_clusterChangeNotifier.notifyListeners();
}
Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) { Fluster<GeoEntry> _buildFluster({int nodeSize = 64}) {
final markers = entries.map((entry) { final markers = entries.map((entry) {
final latLng = entry.latLng!; final latLng = entry.latLng!;
@ -259,7 +300,7 @@ class _GeoMapState extends State<GeoMap> {
Map<MarkerKey, GeoEntry> _buildMarkerClusters() { Map<MarkerKey, GeoEntry> _buildMarkerClusters() {
final bounds = _boundsNotifier.value; final bounds = _boundsNotifier.value;
final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round()); final geoEntries = _defaultMarkerCluster?.clusters(bounds.boundingBox, bounds.zoom.round()) ?? [];
return Map.fromEntries(geoEntries.map((v) { return Map.fromEntries(geoEntries.map((v) {
if (v.isCluster!) { if (v.isCluster!) {
final uri = v.childMarkerId; final uri = v.childMarkerId;

View file

@ -21,6 +21,7 @@ import 'package:provider/provider.dart';
class EntryGoogleMap extends StatefulWidget { class EntryGoogleMap extends StatefulWidget {
final AvesMapController? controller; final AvesMapController? controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier; final ValueNotifier<ZoomedBounds> boundsNotifier;
final double? minZoom, maxZoom; final double? minZoom, maxZoom;
final EntryMapStyle style; final EntryMapStyle style;
@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget {
const EntryGoogleMap({ const EntryGoogleMap({
Key? key, Key? key,
this.controller, this.controller,
required this.clusterListenable,
required this.boundsNotifier, required this.boundsNotifier,
this.minZoom, this.minZoom,
this.maxZoom, this.maxZoom,
@ -93,9 +95,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
if (avesMapController != null) { if (avesMapController != null) {
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng)))); _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
} }
widget.clusterListenable.addListener(_updateMarkers);
} }
void _unregisterWidget(EntryGoogleMap widget) { void _unregisterWidget(EntryGoogleMap widget) {
widget.clusterListenable.removeListener(_updateMarkers);
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
@ -109,7 +113,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
case AppLifecycleState.detached: case AppLifecycleState.detached:
break; break;
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
// workaround for blank Google Maps when resuming app // workaround for blank Google map when resuming app
// cf https://github.com/flutter/flutter/issues/40284 // cf https://github.com/flutter/flutter/issues/40284
_googleMapController?.setMapStyle(null); _googleMapController?.setMapStyle(null);
break; break;
@ -171,13 +175,14 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
builder: (context, dotEntry, child) { builder: (context, dotEntry, child) {
return GoogleMap( return GoogleMap(
initialCameraPosition: CameraPosition( initialCameraPosition: CameraPosition(
bearing: -bounds.rotation,
target: _toGoogleLatLng(bounds.center), target: _toGoogleLatLng(bounds.center),
zoom: bounds.zoom, zoom: bounds.zoom,
), ),
onMapCreated: (controller) async { onMapCreated: (controller) async {
_googleMapController = controller; _googleMapController = controller;
final zoom = await controller.getZoomLevel(); final zoom = await controller.getZoomLevel();
await _updateVisibleRegion(zoom: zoom, rotation: 0); await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation);
setState(() {}); setState(() {});
}, },
// compass disabled to use provider agnostic controls // compass disabled to use provider agnostic controls
@ -212,7 +217,8 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
onCameraIdle: _onIdle, onCameraIdle: _onIdle,
onTap: (position) => widget.onMapTap?.call(), onTap: (position) => widget.onMapTap?.call(),
); );
}); },
);
}, },
); );
} }
@ -220,6 +226,10 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
void _onIdle() { void _onIdle() {
if (!mounted) return; if (!mounted) return;
widget.controller?.notifyIdle(bounds); widget.controller?.notifyIdle(bounds);
_updateMarkers();
}
void _updateMarkers() {
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
} }

View file

@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// generate bitmap from widget, for Google Maps // generate bitmap from widget, for Google map
class MarkerGeneratorWidget<T extends Key> extends StatefulWidget { class MarkerGeneratorWidget<T extends Key> extends StatefulWidget {
final List<Widget> markers; final List<Widget> markers;
final bool Function(T markerKey) isReadyToRender; final bool Function(T markerKey) isReadyToRender;

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/debouncer.dart'; import 'package:aves/utils/debouncer.dart';
import 'package:aves/widgets/common/map/buttons.dart'; import 'package:aves/widgets/common/map/buttons.dart';
@ -22,6 +23,7 @@ import 'package:provider/provider.dart';
class EntryLeafletMap extends StatefulWidget { class EntryLeafletMap extends StatefulWidget {
final AvesMapController? controller; final AvesMapController? controller;
final Listenable clusterListenable;
final ValueNotifier<ZoomedBounds> boundsNotifier; final ValueNotifier<ZoomedBounds> boundsNotifier;
final double minZoom, maxZoom; final double minZoom, maxZoom;
final EntryMapStyle style; final EntryMapStyle style;
@ -37,6 +39,7 @@ class EntryLeafletMap extends StatefulWidget {
const EntryLeafletMap({ const EntryLeafletMap({
Key? key, Key? key,
this.controller, this.controller,
required this.clusterListenable,
required this.boundsNotifier, required this.boundsNotifier,
this.minZoom = 0, this.minZoom = 0,
this.maxZoom = 22, this.maxZoom = 22,
@ -66,7 +69,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
ZoomedBounds get bounds => boundsNotifier.value; ZoomedBounds get bounds => boundsNotifier.value;
// duration should match the uncustomizable Google Maps duration // duration should match the uncustomizable Google map duration
static const _cameraAnimationDuration = Duration(milliseconds: 600); static const _cameraAnimationDuration = Duration(milliseconds: 600);
@override @override
@ -95,11 +98,13 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng))); _subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng)));
} }
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion())); _subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
boundsNotifier.addListener(_onBoundsChange); widget.clusterListenable.addListener(_updateMarkers);
widget.boundsNotifier.addListener(_onBoundsChange);
} }
void _unregisterWidget(EntryLeafletMap widget) { void _unregisterWidget(EntryLeafletMap widget) {
boundsNotifier.removeListener(_onBoundsChange); widget.clusterListenable.removeListener(_updateMarkers);
widget.boundsNotifier.removeListener(_onBoundsChange);
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
@ -151,6 +156,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
options: MapOptions( options: MapOptions(
center: bounds.center, center: bounds.center,
zoom: bounds.zoom, zoom: bounds.zoom,
rotation: bounds.rotation,
minZoom: widget.minZoom, minZoom: widget.minZoom,
maxZoom: widget.maxZoom, maxZoom: widget.maxZoom,
// TODO TLAD [map] as of flutter_map v0.14.0, `doubleTapZoom` does not move when zoom is already maximal // TODO TLAD [map] as of flutter_map v0.14.0, `doubleTapZoom` does not move when zoom is already maximal
@ -162,7 +168,9 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
mapController: _leafletMapController, mapController: _leafletMapController,
nonRotatedChildren: [ nonRotatedChildren: [
ScaleLayerWidget( ScaleLayerWidget(
options: ScaleLayerOptions(), options: ScaleLayerOptions(
unitSystem: settings.unitSystem,
),
), ),
], ],
children: [ children: [
@ -212,6 +220,10 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
void _onIdle() { void _onIdle() {
if (!mounted) return; if (!mounted) return;
widget.controller?.notifyIdle(bounds); widget.controller?.notifyIdle(bounds);
_updateMarkers();
}
void _updateMarkers() {
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
} }

View file

@ -1,5 +1,4 @@
import 'dart:math'; import 'package:aves/model/settings/enums.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart'; import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart'; import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -7,10 +6,12 @@ import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/plugin_api.dart'; import 'package:flutter_map/plugin_api.dart';
class ScaleLayerOptions extends LayerOptions { class ScaleLayerOptions extends LayerOptions {
final UnitSystem unitSystem;
final Widget Function(double width, String distance) builder; final Widget Function(double width, String distance) builder;
ScaleLayerOptions({ ScaleLayerOptions({
Key? key, Key? key,
this.unitSystem = UnitSystem.metric,
this.builder = defaultBuilder, this.builder = defaultBuilder,
rebuild, rebuild,
}) : super(key: key, rebuild: rebuild); }) : super(key: key, rebuild: rebuild);
@ -41,7 +42,7 @@ class ScaleLayer extends StatelessWidget {
// ignore: prefer_void_to_null // ignore: prefer_void_to_null
final Stream<Null> stream; final Stream<Null> stream;
final scale = [ static const List<double> scaleMeters = [
25000000, 25000000,
15000000, 15000000,
8000000, 8000000,
@ -67,6 +68,10 @@ class ScaleLayer extends StatelessWidget {
5, 5,
]; ];
static const double metersInAKilometer = 1000;
static const double metersInAMile = 1609.344;
static const double metersInAFoot = 0.3048;
ScaleLayer(this.scaleLayerOpts, this.map, this.stream) : super(key: scaleLayerOpts.key); ScaleLayer(this.scaleLayerOpts, this.map, this.stream) : super(key: scaleLayerOpts.key);
@override @override
@ -83,11 +88,33 @@ class ScaleLayer extends StatelessWidget {
: latitude > 60 : latitude > 60
? 3 ? 3
: 2); : 2);
final distance = scale[max(0, min(20, level))].toDouble(); final scaleLevel = level.clamp(0, 20);
late final double distanceMeters;
late final String displayDistance;
switch (scaleLayerOpts.unitSystem) {
case UnitSystem.metric:
// meters
distanceMeters = scaleMeters[scaleLevel];
displayDistance = distanceMeters >= metersInAKilometer ? '${(distanceMeters / metersInAKilometer).toStringAsFixed(0)} km' : '${distanceMeters.toStringAsFixed(0)} m';
break;
case UnitSystem.imperial:
if (scaleLevel < 15) {
// miles
final distanceMiles = scaleMeters[scaleLevel + 1] / 1000;
distanceMeters = distanceMiles * metersInAMile;
displayDistance = '${distanceMiles.toStringAsFixed(0)} mi';
} else {
// feet
final distanceFeet = scaleMeters[scaleLevel - 1];
distanceMeters = distanceFeet * metersInAFoot;
displayDistance = '${distanceFeet.toStringAsFixed(0)} ft';
}
break;
}
final start = map.project(center); final start = map.project(center);
final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance); final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distanceMeters);
final end = map.project(targetPoint); final end = map.project(targetPoint);
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
final width = end.x - (start.x as double); final width = end.x - (start.x as double);
return scaleLayerOpts.builder(width, displayDistance); return scaleLayerOpts.builder(width, displayDistance);

View file

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
class ScaleBarUtils { class ScaleBarUtils {
static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) { static LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distanceMeters) {
var mSemiMajorAxis = 6378137.0; //WGS84 major axis var mSemiMajorAxis = 6378137.0; //WGS84 major axis
var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0; var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0;
var mFlattening = 1.0 / 298.257223563; var mFlattening = 1.0 / 298.257223563;
@ -18,7 +18,7 @@ class ScaleBarUtils {
var alpha1 = degToRadian(startBearing); var alpha1 = degToRadian(startBearing);
var cosAlpha1 = cos(alpha1); var cosAlpha1 = cos(alpha1);
var sinAlpha1 = sin(alpha1); var sinAlpha1 = sin(alpha1);
var s = distance; var s = distanceMeters;
var tanU1 = (1.0 - f) * tan(phi1); var tanU1 = (1.0 - f) * tan(phi1);
var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1); var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1);
var sinU1 = tanU1 * cosU1; var sinU1 = tanU1 * cosU1;

View file

@ -39,7 +39,7 @@ class ImageMarker extends StatelessWidget {
) )
: const SizedBox(); : const SizedBox();
// need to be sized for the Google Maps marker generator // need to be sized for the Google map marker generator
child = SizedBox( child = SizedBox(
width: extent, width: extent,
height: extent, height: extent,

View file

@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
enum MapNavigationButton { back, map } enum MapNavigationButton { back, map }
class MapTheme extends StatelessWidget { class MapTheme extends StatelessWidget {
final bool interactive; final bool interactive, showCoordinateFilter;
final MapNavigationButton navigationButton; final MapNavigationButton navigationButton;
final Animation<double> scale; final Animation<double> scale;
final VisualDensity? visualDensity; final VisualDensity? visualDensity;
@ -15,6 +15,7 @@ class MapTheme extends StatelessWidget {
const MapTheme({ const MapTheme({
Key? key, Key? key,
required this.interactive, required this.interactive,
required this.showCoordinateFilter,
required this.navigationButton, required this.navigationButton,
this.scale = kAlwaysCompleteAnimation, this.scale = kAlwaysCompleteAnimation,
this.visualDensity, this.visualDensity,
@ -28,6 +29,7 @@ class MapTheme extends StatelessWidget {
update: (context, settings, __) { update: (context, settings, __) {
return MapThemeData( return MapThemeData(
interactive: interactive, interactive: interactive,
showCoordinateFilter: showCoordinateFilter,
navigationButton: navigationButton, navigationButton: navigationButton,
scale: scale, scale: scale,
visualDensity: visualDensity, visualDensity: visualDensity,
@ -40,7 +42,7 @@ class MapTheme extends StatelessWidget {
} }
class MapThemeData { class MapThemeData {
final bool interactive; final bool interactive, showCoordinateFilter;
final MapNavigationButton navigationButton; final MapNavigationButton navigationButton;
final Animation<double> scale; final Animation<double> scale;
final VisualDensity? visualDensity; final VisualDensity? visualDensity;
@ -48,6 +50,7 @@ class MapThemeData {
const MapThemeData({ const MapThemeData({
required this.interactive, required this.interactive,
required this.showCoordinateFilter,
required this.navigationButton, required this.navigationButton,
required this.scale, required this.scale,
required this.visualDensity, required this.visualDensity,

Some files were not shown because too many files have changed in this diff Show more