Merge branch 'develop'
This commit is contained in:
commit
6c5ac871b8
163 changed files with 4171 additions and 1430 deletions
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
30
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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.
|
2
.github/workflows/check.yml
vendored
2
.github/workflows/check.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: '2.5.1'
|
||||
flutter-version: '2.5.3'
|
||||
|
||||
- name: Clone the repository.
|
||||
uses: actions/checkout@v2
|
||||
|
|
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
|||
- uses: subosito/flutter-action@v1
|
||||
with:
|
||||
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):
|
||||
# https://issuetracker.google.com/issues/144111441
|
||||
|
@ -50,8 +50,9 @@ jobs:
|
|||
echo "${{ secrets.KEY_JKS }}" > release.keystore.asc
|
||||
gpg -d --passphrase "${{ secrets.KEY_JKS_PASSPHRASE }}" --batch release.keystore.asc > $AVES_STORE_FILE
|
||||
rm release.keystore.asc
|
||||
flutter build apk --bundle-sksl-path shaders_2.5.1.sksl.json
|
||||
flutter build appbundle --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 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
|
||||
env:
|
||||
AVES_STORE_FILE: ${{ github.workspace }}/key.jks
|
||||
|
@ -63,14 +64,14 @@ jobs:
|
|||
- name: Create a release with the APK and App Bundle.
|
||||
uses: ncipollo/release-action@v1
|
||||
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 }}
|
||||
|
||||
- name: Upload app bundle
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: appbundle
|
||||
path: build/app/outputs/bundle/release/app-release.aab
|
||||
path: build/app/outputs/bundle/universalRelease/app-universal-release.aab
|
||||
|
||||
release:
|
||||
name: Create beta release on Play Store.
|
||||
|
@ -89,7 +90,7 @@ jobs:
|
|||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }}
|
||||
packageName: deckers.thibault.aves
|
||||
releaseFiles: app-release.aab
|
||||
releaseFiles: app-universal-release.aab
|
||||
track: beta
|
||||
status: completed
|
||||
whatsNewDirectory: whatsnew
|
||||
|
|
123
CHANGELOG.md
123
CHANGELOG.md
|
@ -1,50 +1,88 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.5.3] - 2021-09-30
|
||||
### 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
|
||||
- Info: remove metadata (Exif, XMP, etc.)
|
||||
- Accessibility: support "time to take action" and "remove animations" settings
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v2.5.1
|
||||
- faster collection loading when launching the app
|
||||
- Collection: changed color & scale of thumbnail icons to match text
|
||||
- Albums / Countries / Tags: changed layout, with label below cover
|
||||
|
||||
### Fixed
|
||||
|
||||
- album bookmarks & pins were reset when rescanning items
|
||||
|
||||
## [v1.5.2] - 2021-09-29 [YANKED]
|
||||
|
||||
## [v1.5.1] - 2021-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- About: bug reporting instructions
|
||||
|
||||
### Changed
|
||||
|
||||
- Collection: improved video date detection
|
||||
|
||||
### Fixed
|
||||
|
||||
- fixed hanging app when loading thumbnails for some video formats on some devices
|
||||
|
||||
## [v1.5.0] - 2021-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Info: edit Exif dates (setting, shifting, deleting)
|
||||
- Collection: custom quick actions for item selection
|
||||
- Collection: video date detection for more formats
|
||||
|
||||
### Changed
|
||||
|
||||
- faster collection loading when launching the app
|
||||
|
||||
### Fixed
|
||||
|
||||
- app launching on some devices
|
||||
- corrupting motion photo exif editing (e.g. rotation)
|
||||
|
||||
## [v1.4.9] - 2021-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- Map & Stats from selection
|
||||
- Map: item browsing, rotation control
|
||||
- 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)
|
||||
|
||||
## [v1.4.8] - 2021-08-08
|
||||
|
||||
### Added
|
||||
|
||||
- Map
|
||||
- Viewer: action to copy to clipboard
|
||||
- integration with Android global search (Samsung Finder etc.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- auto album identification and naming
|
||||
- opening HEIC images from downloads content URI on Android R+
|
||||
|
||||
## [v1.4.7] - 2021-08-06 [YANKED]
|
||||
|
||||
## [v1.4.6] - 2021-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- Albums / Countries / Tags: multiple selection
|
||||
- Albums: action to create empty albums
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
|
||||
- Video: restored overlay hiding when pressing play button
|
||||
|
||||
### Fixed
|
||||
|
||||
- Viewer: fixed manual screen rotation to follow sensor
|
||||
|
||||
## [v1.4.5] - 2021-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- Video: added OGV/Theora/Vorbis support
|
||||
- Viewer: action to rotate screen when device has locked rotation
|
||||
- Settings: import/export
|
||||
|
||||
### Changed
|
||||
|
||||
- improved SVG support with a different rendering engine
|
||||
- changed logo
|
||||
- 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
|
||||
|
||||
### Removed
|
||||
|
||||
- Analytics: removed Firebase Analytics (kept Firebase Crashlytics)
|
||||
|
||||
## [v1.4.4] - 2021-06-25
|
||||
|
||||
### Added
|
||||
|
||||
- Video: speed control, track selection, frame capture
|
||||
- Video: embedded subtitle support
|
||||
- Settings: custom video quick actions
|
||||
- Settings: subtitle theme
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v2.2.2
|
||||
|
||||
### Fixed
|
||||
|
||||
- fixed opening SVGs from other apps
|
||||
- stop video playback when leaving the app in some cases
|
||||
- fixed crash when ACCESS_MEDIA_LOCATION permission is revoked
|
||||
|
||||
## [v1.4.3] - 2021-06-12
|
||||
|
||||
### Added
|
||||
|
||||
- Collection: snack bar action to show moved/copied/exported entries
|
||||
- Collection / Albums / Countries / Tags: when switching device orientation, keep items in view
|
||||
- Collection: when leaving entry from Viewer, make entry visible in collection
|
||||
- Viewer: fixed layout & minimap for videos with non-square pixels
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v2.2.1
|
||||
- migrated to unsound null safety
|
||||
- Collection / Viewer: improved performance, memory usage
|
||||
- Collection: thumbnail layout change
|
||||
|
||||
### Removed
|
||||
|
||||
- no support for Android KitKat (API 19), unsupported by Google Maps package
|
||||
|
||||
### Fixed
|
||||
|
||||
- fixed opening files shared via content URI with incorrect MIME type
|
||||
- refresh collection when entries modified in Viewer no longer match collection filters
|
||||
|
||||
## [v1.4.2] - 2021-06-10 [YANKED]
|
||||
|
||||
## [v1.4.1] - 2021-04-29
|
||||
|
||||
### Added
|
||||
|
||||
- Motion photo support
|
||||
- Viewer: play videos in multi-track HEIC
|
||||
- Handle share intent
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Flutter to beta v2.2.0-10.1.pre
|
||||
|
||||
### Fixed
|
||||
|
||||
- fixed crash when cataloguing large MP4/PSD
|
||||
- prevent videos playing in the background when quickly switching entries
|
||||
|
||||
## [v1.4.0] - 2021-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- Viewer: support for videos with EAC3/FLAC/OPUS audio
|
||||
- Info: more consistent and comprehensive info for videos and streams
|
||||
- Settings: more video options (auto play, loop, hardware acceleration)
|
||||
|
||||
### Changed
|
||||
|
||||
- Info: present video cover like XMP embedded images
|
||||
|
||||
### Removed
|
||||
|
||||
- locale name package (-3 MB)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Albums: auto naming for folders on SD card
|
||||
- Viewer: display of videos with unusual SAR
|
||||
|
||||
## [v1.3.7] - 2021-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
|
||||
- Albums: localized common album names
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Flutter to beta v2.1.0-12.2.pre
|
||||
|
||||
### Fixed
|
||||
|
||||
- opening media shared by other apps as file media content
|
||||
- navigation stack when opening media shared by other apps
|
||||
|
||||
## [v1.3.6] - 2021-03-18
|
||||
|
||||
### Added
|
||||
|
||||
- Korean translation
|
||||
- cover selection for albums / countries / tags
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Flutter to dev v2.1.0-12.1.pre
|
||||
|
||||
### Fixed
|
||||
|
||||
- various TIFF decoding fixes
|
||||
|
||||
## [v1.3.5] - 2021-02-26
|
||||
|
||||
### Added
|
||||
|
||||
- support Android KitKat, Lollipop & Marshmallow (API 19 ~ 23)
|
||||
- quick country reverse geocoding without Play Services
|
||||
- menu option to hide any filter
|
||||
- menu option to navigate to the album / country / tag page from filter
|
||||
|
||||
### Changed
|
||||
|
||||
- analytics are opt-in
|
||||
|
||||
### Removed
|
||||
|
||||
- removed custom font used in titles and info page
|
||||
|
||||
## [v1.3.4] - 2021-02-10
|
||||
|
||||
### Added
|
||||
|
||||
- hide album / country / tag from collection
|
||||
- new version check
|
||||
|
||||
### Changed
|
||||
|
||||
- Viewer: improved multipage item overlay and thumbnail loading
|
||||
- deactivate geocoding and Google maps when Play Services are unavailable
|
||||
|
||||
### Fixed
|
||||
|
||||
- refreshing items externally added/moved/removed
|
||||
- loading items at the root of volumes
|
||||
- loading items when opening a shortcut with a location filter
|
||||
- various thumbnail hero animation fixes
|
||||
|
||||
## [v1.3.3] - 2021-01-31
|
||||
|
||||
### Added
|
||||
|
||||
- Viewer: support for multi-track HEIF
|
||||
- Viewer: export image (including multipage TIFF/HEIF and images embedded in XMP)
|
||||
- Info: show owner app (Android Q and up)
|
||||
- listen to Media Store changes
|
||||
|
||||
### Changed
|
||||
|
||||
- upgraded Flutter to stable v1.22.6
|
||||
- check connectivity before using features that need it
|
||||
|
||||
### Fixed
|
||||
|
||||
- checkerboard background performance
|
||||
- deleting files that no longer exist but are still registered in the Media Store
|
||||
- insets handling on Android 11
|
||||
|
||||
## [v1.3.2] - 2021-01-17
|
||||
|
||||
### Added
|
||||
Collection: identify multipage TIFF & multitrack HEIC/HEIF
|
||||
Viewer: support for multipage TIFF
|
||||
Viewer: support for cropped panoramas
|
||||
Albums: grouping options
|
||||
|
||||
Collection: identify multipage TIFF & multitrack HEIC/HEIF Viewer: support for multipage TIFF
|
||||
Viewer: support for cropped panoramas Albums: grouping options
|
||||
|
||||
### Changed
|
||||
|
||||
upgraded libtiff to 4.2.0 for TIFF decoding
|
||||
|
||||
### Fixed
|
||||
|
||||
- prevent scrolling when using Android Q style gesture navigation
|
||||
|
||||
## [v1.3.1] - 2021-01-04
|
||||
|
||||
### Added
|
||||
|
||||
- Collection: long press and move to select/deselect multiple items
|
||||
- Info: show Spherical Video V1 metadata
|
||||
- Info: metadata search
|
||||
|
||||
### Fixed
|
||||
|
||||
- Viewer: fixed panning inertia following double-tap scaling
|
||||
- Collection: fixed crash when loading TIFF files on Android 11
|
||||
|
||||
## [v1.3.0] - 2020-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- Viewer: quick scale (aka one finger zoom)
|
||||
- Viewer: optional checkered background for transparent images
|
||||
|
||||
### Changed
|
||||
|
||||
- Viewer: changed panning inertia
|
||||
|
||||
### Fixed
|
||||
|
||||
- Viewer: fixed scaling focus when zooming by double-tap or pinch
|
||||
- Viewer: fixed panning during scaling
|
||||
|
||||
## [v1.2.9] - 2020-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- Collection: identify 360 photos/videos, GeoTIFF
|
||||
- Viewer: open panoramas (360 photos)
|
||||
- Info: open GImage/GAudio/GDepth media and thumbnails embedded in XMP
|
||||
- Info: SVG metadata
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Flutter to stable v1.22.5
|
||||
- Viewer: TIFF subsampling & tiling
|
||||
- Info: improved XMP layout
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed large TIFF handling
|
||||
|
||||
## [v1.2.8] - 2020-11-27
|
||||
|
||||
### Added
|
||||
|
||||
- Albums / Countries / Tags: pinch to change tile size
|
||||
- Album picker: added a field to filter by name
|
||||
- check free space before moving items
|
||||
- SVG source viewer
|
||||
|
||||
### Changed
|
||||
|
||||
- Navigation: changed page history handling
|
||||
- Info: improved layout, especially for XMP
|
||||
- About: improved layout
|
||||
- faster locating of new items
|
||||
|
||||
## [v1.2.7] - 2020-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- Support for TIFF images (single page)
|
||||
- Viewer overlay: minimap (optional)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Flutter to stable v1.22.4
|
||||
- Viewer: use subsampling and tiling to display large images
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed finding dimensions of items with incorrect EXIF
|
||||
|
||||
## [v1.2.6] - 2020-11-15 [YANKED]
|
||||
|
||||
## [v1.2.5] - 2020-11-01
|
||||
|
||||
### Added
|
||||
|
||||
- Search: show recently used filters (optional)
|
||||
- Search: show filter for items with no XMP tags
|
||||
- Search: show filter for items with no location information
|
||||
- Analytics: use Firebase Analytics (along Firebase Crashlytics)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Flutter to stable v1.22.3
|
||||
- Viewer overlay: showing shooting details is now optional
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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
|
||||
- Info: prevent reporting a "Media" section for images other than HEIC/HEIF
|
||||
- Fixed opening items shared via a "file" media content URI
|
||||
|
||||
### Removed
|
||||
|
||||
- Dependencies: removed Guava as a direct dependency in Android
|
||||
|
||||
## [v1.2.4] - 2020-11-01 [YANKED]
|
||||
|
||||
## [v1.2.3] - 2020-10-22
|
||||
|
||||
...
|
21
README.md
21
README.md
|
@ -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,
|
||||
- **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
|
||||
|
||||
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
|
||||
[Build badge]: https://img.shields.io/github/workflow/status/deckerst/aves/Quality%20check
|
||||
|
|
|
@ -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 {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
|
@ -87,19 +102,23 @@ android {
|
|||
resValue 'string', 'search_provider', "${appId}.profile.search_provider"
|
||||
}
|
||||
release {
|
||||
// specify architectures, to specifically exclude native libs for x86,
|
||||
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
|
||||
// cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
}
|
||||
|
||||
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 {
|
||||
// specify architectures, to specifically exclude native libs for x86,
|
||||
// which lead to: UnsatisfiedLinkError...couldn't find "libflutter.so"
|
||||
// cf https://github.com/flutter/flutter/issues/37566#issuecomment-640879500
|
||||
ndk {
|
||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,10 +139,10 @@ dependencies {
|
|||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.commonsware.cwac:document:0.4.1'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.16.0'
|
||||
// https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a' // forked, built by JitPack
|
||||
// https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2' // forked, built by JitPack
|
||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:pixymeta-android:0bea51ead2'
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
kapt 'androidx.annotation:annotation:1.2.0'
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<resources>
|
||||
<string name="app_name">아베스 [Debug]</string>
|
||||
<string name="app_name">아베스 [Debug]</string>
|
||||
</resources>
|
|
@ -16,6 +16,7 @@
|
|||
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" />
|
||||
<!-- request write permission until Q (29) included, because scoped storage is unusable -->
|
||||
<uses-permission
|
||||
|
@ -122,6 +123,10 @@
|
|||
android:name="android.app.searchable"
|
||||
android:resource="@xml/searchable" />
|
||||
</activity>
|
||||
<service
|
||||
android:name=".AnalysisService"
|
||||
android:description="@string/analysis_service_description"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- file provider to share files having a file:// URI -->
|
||||
<provider
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -15,19 +15,21 @@ import androidx.core.graphics.drawable.IconCompat
|
|||
import app.loup.streams_channel.StreamsChannel
|
||||
import deckers.thibault.aves.channel.calls.*
|
||||
import deckers.thibault.aves.channel.streams.*
|
||||
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
||||
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
|
||||
private lateinit var intentStreamHandler: IntentStreamHandler
|
||||
private lateinit var analysisStreamHandler: AnalysisStreamHandler
|
||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
private lateinit var analysisHandler: AnalysisHandler
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
|
@ -52,24 +54,30 @@ class MainActivity : FlutterActivity() {
|
|||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
||||
// 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, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
||||
MethodChannel(messenger, MediaFileHandler.CHANNEL).setMethodCallHandler(MediaFileHandler(this))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(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))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
// - need Context
|
||||
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) }
|
||||
// - need Activity
|
||||
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
||||
|
||||
// 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
|
||||
errorStreamHandler = ErrorStreamHandler().apply {
|
||||
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() {
|
||||
Log.i(LOG_TAG, "onDestroy")
|
||||
mediaStoreChangeStreamHandler.dispose()
|
||||
settingsChangeStreamHandler.dispose()
|
||||
super.onDestroy()
|
||||
|
@ -122,7 +148,8 @@ class MainActivity : FlutterActivity() {
|
|||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
when (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,
|
||||
OPEN_FILE_REQUEST,
|
||||
SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
|
||||
|
@ -147,10 +174,9 @@ class MainActivity : FlutterActivity() {
|
|||
onStorageAccessResult(requestCode, treeUri)
|
||||
}
|
||||
|
||||
private fun onDeletePermissionResult(resultCode: Int) {
|
||||
// delete permission may be requested on Android 10+ only
|
||||
private fun onScopedStoragePermissionResult(resultCode: Int) {
|
||||
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))
|
||||
}
|
||||
|
||||
private fun onAnalysisCompleted() {
|
||||
analysisStreamHandler.notifyCompletion()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MainActivity>()
|
||||
const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer"
|
||||
const val EXTRA_STRING_ARRAY_SEPARATOR = "###"
|
||||
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 OPEN_FILE_REQUEST = 4
|
||||
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
|
||||
val pendingStorageAccessResultHandlers = ConcurrentHashMap<Int, PendingStorageAccessResultHandler>()
|
||||
|
||||
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
|
||||
|
||||
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
|
||||
Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
|
||||
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
|
||||
|
|
|
@ -2,24 +2,21 @@ package deckers.thibault.aves
|
|||
|
||||
import android.app.SearchManager
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
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 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.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.view.FlutterCallbackInformation
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
@ -71,7 +68,9 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
|
||||
private suspend fun getSuggestions(context: Context, query: String): List<FieldMap> {
|
||||
if (backgroundFlutterEngine == null) {
|
||||
initFlutterEngine(context)
|
||||
FlutterUtils.initFlutterEngine(context, SHARED_PREFERENCES_KEY, CALLBACK_HANDLE_KEY) {
|
||||
backgroundFlutterEngine = it
|
||||
}
|
||||
}
|
||||
|
||||
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
@ -86,7 +85,7 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
"locale" to Locale.getDefault().toString(),
|
||||
), object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Suppress("unchecked_cast")
|
||||
cont.resume(result as List<FieldMap>)
|
||||
}
|
||||
|
||||
|
@ -133,60 +132,5 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
|
|||
const val CALLBACK_HANDLE_KEY = "callback_handle"
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
try {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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>()
|
||||
|
||||
fun addPackageDetails(intent: Intent) {
|
||||
|
@ -76,7 +76,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
// The following methods do not work:
|
||||
// - `resources.getConfiguration().setLocale(...)`
|
||||
// - getting a package manager from a custom context with `context.createConfigurationContext(config)`
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
resources.updateConfiguration(englishConfig, resources.displayMetrics)
|
||||
englishLabel = resources.getString(labelRes)
|
||||
} catch (e: Exception) {
|
||||
|
@ -321,7 +321,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
"cacheDir" to context.cacheDir,
|
||||
"filesDir" to context.filesDir,
|
||||
|
@ -83,7 +83,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
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())
|
||||
}
|
||||
|
||||
|
|
|
@ -16,14 +16,17 @@ 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
||||
return
|
||||
val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
||||
if (performanceClass > 0) {
|
||||
result.success(performanceClass)
|
||||
return
|
||||
}
|
||||
}
|
||||
result.success(Build.VERSION.SDK_INT)
|
||||
}
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.SearchSuggestionsProvider
|
||||
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.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -13,7 +11,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.GlobalScope
|
||||
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) {
|
||||
when (call.method) {
|
||||
"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) {
|
||||
val callbackHandle = call.argument<Number>("callbackHandle")?.toLong()
|
||||
if (callbackHandle == null) {
|
||||
|
@ -36,7 +35,6 @@ class GlobalSearchHandler(private val context: Activity) : MethodCallHandler {
|
|||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<GlobalSearchHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/global_search"
|
||||
}
|
||||
}
|
|
@ -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.TiffRegionFetcher
|
||||
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.ImageProviderFactory.getProvider
|
||||
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) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
|
||||
"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) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -144,7 +144,8 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
||||
val bytes = call.argument<ByteArray>("bytes")
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -156,41 +157,13 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
}
|
||||
|
||||
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 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) {
|
||||
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) {
|
||||
private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
Glide.get(activity).clearDiskCache()
|
||||
result.success(null)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
|
@ -12,7 +12,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.GlobalScope
|
||||
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) {
|
||||
when (call.method) {
|
||||
"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)
|
||||
return
|
||||
}
|
||||
result.success(MediaStoreImageProvider().checkObsoleteContentIds(activity, knownContentIds))
|
||||
result.success(MediaStoreImageProvider().checkObsoleteContentIds(context, knownContentIds))
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
result.success(MediaStoreImageProvider().checkObsoletePaths(activity, knownPathById))
|
||||
result.success(MediaStoreImageProvider().checkObsoletePaths(context, knownPathById))
|
||||
}
|
||||
|
||||
private fun scanFile(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
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 {
|
||||
|
|
|
@ -12,6 +12,7 @@ import androidx.exifinterface.media.ExifInterface
|
|||
import com.adobe.internal.xmp.XMPException
|
||||
import com.adobe.internal.xmp.properties.XMPPropertyInfo
|
||||
import com.drew.imaging.ImageMetadataReader
|
||||
import com.drew.lang.KeyValuePair
|
||||
import com.drew.lang.Rational
|
||||
import com.drew.metadata.Tag
|
||||
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.getSafeDescription
|
||||
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.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_TIME_DIR_NAME
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.extractPngProfile
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeBoolean
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeInt
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeRational
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
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.getSafeInt
|
||||
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.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
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.tiffExtensionPattern
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -66,6 +71,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.text.ParseException
|
||||
import java.util.*
|
||||
import kotlin.math.roundToLong
|
||||
|
@ -104,113 +110,158 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
foundExif = metadata.containsDirectoryOfType(ExifDirectoryBase::class.java)
|
||||
foundXmp = metadata.containsDirectoryOfType(XmpDirectory::class.java)
|
||||
val uuidDirCount = HashMap<String, Int>()
|
||||
for (dir in metadata.directories.filter {
|
||||
val dirByName = metadata.directories.filter {
|
||||
it.tagCount > 0
|
||||
&& it !is FileTypeDirectory
|
||||
&& it !is AviDirectory
|
||||
}) {
|
||||
// directory name
|
||||
var dirName = dir.name
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}.groupBy { dir -> dir.name }
|
||||
for (dirEntry in dirByName) {
|
||||
val baseDirName = dirEntry.key
|
||||
|
||||
// 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`
|
||||
// would also exclude derived directories, such as `Mp4UuidBoxDirectory`
|
||||
if (allMetadataRedundantDirNames.contains(dirName)) continue
|
||||
if (allMetadataRedundantDirNames.contains(baseDirName)) continue
|
||||
|
||||
// optional parent to distinguish child directories of the same type
|
||||
dir.parent?.name?.let { dirName = "$it/$dirName" }
|
||||
val sameNameDirs = dirEntry.value
|
||||
val sameNameDirCount = sameNameDirs.size
|
||||
for (dirIndex in 0 until sameNameDirCount) {
|
||||
val dir = sameNameDirs[dirIndex]
|
||||
|
||||
val dirMap = metadataMap[dirName] ?: HashMap()
|
||||
metadataMap[dirName] = dirMap
|
||||
// directory name
|
||||
var thisDirName = baseDirName
|
||||
if (dir is Mp4UuidBoxDirectory) {
|
||||
val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-')
|
||||
thisDirName += " $uuid"
|
||||
|
||||
// tags
|
||||
val tags = dir.tags
|
||||
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) {
|
||||
fun tagMapper(it: Tag): Pair<String, String> {
|
||||
val name = if (it.hasTagName()) {
|
||||
it.tagName
|
||||
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
|
||||
dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
|
||||
|
||||
var dirMap = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = dirMap
|
||||
|
||||
// tags
|
||||
val tags = dir.tags
|
||||
if (mimeType == MimeTypes.TIFF && (dir is ExifIFD0Directory || dir is ExifThumbnailDirectory)) {
|
||||
fun tagMapper(it: Tag): Pair<String, String> {
|
||||
val name = if (it.hasTagName()) {
|
||||
it.tagName
|
||||
} else {
|
||||
TiffTags.getTagName(it.tagType) ?: it.tagName
|
||||
}
|
||||
return Pair(name, it.description)
|
||||
}
|
||||
|
||||
if (dir is ExifIFD0Directory && dir.isGeoTiff()) {
|
||||
// split GeoTIFF tags in their own directory
|
||||
val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) }
|
||||
metadataMap["GeoTIFF"] = HashMap<String, String>().apply {
|
||||
byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) }
|
||||
}
|
||||
byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) }
|
||||
} else {
|
||||
TiffTags.getTagName(it.tagType) ?: it.tagName
|
||||
dirMap.putAll(tags.map { tagMapper(it) })
|
||||
}
|
||||
return Pair(name, it.description)
|
||||
}
|
||||
} else if (dir.isPngTextDir()) {
|
||||
metadataMap.remove(thisDirName)
|
||||
dirMap = metadataMap[DIR_PNG_TEXTUAL_DATA] ?: HashMap()
|
||||
metadataMap[DIR_PNG_TEXTUAL_DATA] = dirMap
|
||||
|
||||
if (dir is ExifIFD0Directory && dir.isGeoTiff()) {
|
||||
// split GeoTIFF tags in their own directory
|
||||
val byGeoTiff = tags.groupBy { TiffTags.isGeoTiffTag(it.tagType) }
|
||||
metadataMap["GeoTIFF"] = HashMap<String, String>().apply {
|
||||
byGeoTiff[true]?.map { tagMapper(it) }?.let { putAll(it) }
|
||||
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
|
||||
}
|
||||
}
|
||||
byGeoTiff[false]?.map { tagMapper(it) }?.let { dirMap.putAll(it) }
|
||||
} else {
|
||||
dirMap.putAll(tags.map { tagMapper(it) })
|
||||
dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
|
||||
}
|
||||
} else {
|
||||
dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
|
||||
}
|
||||
if (dir is XmpDirectory) {
|
||||
try {
|
||||
for (prop in dir.xmpMeta) {
|
||||
if (prop is XMPPropertyInfo) {
|
||||
val path = prop.path
|
||||
if (path?.isNotEmpty() == true) {
|
||||
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
|
||||
if (value?.isNotEmpty() == true) {
|
||||
dirMap[path] = value
|
||||
if (dir is XmpDirectory) {
|
||||
try {
|
||||
for (prop in dir.xmpMeta) {
|
||||
if (prop is XMPPropertyInfo) {
|
||||
val path = prop.path
|
||||
if (path?.isNotEmpty() == true) {
|
||||
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
|
||||
if (value?.isNotEmpty() == true) {
|
||||
dirMap[path] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
}
|
||||
} catch (e: XMPException) {
|
||||
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
|
||||
// remove this stat as it is not actual XMP data
|
||||
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||
}
|
||||
// remove this stat as it is not actual XMP data
|
||||
dirMap.remove(dir.getTagName(XmpDirectory.TAG_XMP_VALUE_COUNT))
|
||||
}
|
||||
|
||||
if (dir is Mp4UuidBoxDirectory) {
|
||||
when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
|
||||
GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
|
||||
metadataMap.remove(dirName)
|
||||
}
|
||||
QuickTimeMetadata.PROF_UUID -> {
|
||||
// redundant with info derived on the Dart side
|
||||
metadataMap.remove(dirName)
|
||||
}
|
||||
QuickTimeMetadata.USMT_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
|
||||
if (blocks.isNotEmpty()) {
|
||||
metadataMap.remove(dirName)
|
||||
dirName = "QuickTime User Media"
|
||||
val usmt = metadataMap[dirName] ?: HashMap()
|
||||
metadataMap[dirName] = usmt
|
||||
if (dir is Mp4UuidBoxDirectory) {
|
||||
when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
|
||||
GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
|
||||
metadataMap.remove(thisDirName)
|
||||
}
|
||||
QuickTimeMetadata.PROF_UUID -> {
|
||||
// redundant with info derived on the Dart side
|
||||
metadataMap.remove(thisDirName)
|
||||
}
|
||||
QuickTimeMetadata.USMT_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
|
||||
if (blocks.isNotEmpty()) {
|
||||
metadataMap.remove(thisDirName)
|
||||
thisDirName = "QuickTime User Media"
|
||||
val usmt = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = usmt
|
||||
|
||||
blocks.forEach {
|
||||
var key = it.type
|
||||
var value = it.value
|
||||
val language = it.language
|
||||
blocks.forEach {
|
||||
var key = it.type
|
||||
var value = it.value
|
||||
val language = it.language
|
||||
|
||||
var i = 0
|
||||
while (usmt.containsKey(key)) {
|
||||
key = it.type + " (${++i})"
|
||||
var i = 0
|
||||
while (usmt.containsKey(key)) {
|
||||
key = it.type + " (${++i})"
|
||||
}
|
||||
if (language != "und") {
|
||||
value += " ($language)"
|
||||
}
|
||||
usmt[key] = value
|
||||
}
|
||||
if (language != "und") {
|
||||
value += " ($language)"
|
||||
}
|
||||
usmt[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 which case we trust the file extension
|
||||
// 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
|
||||
} else {
|
||||
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
|
||||
|
@ -658,7 +709,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = ImageMetadataReader.readMetadata(input)
|
||||
val fields = hashMapOf<String, Any?>(
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"projectionType" to XMP.GPANO_PROJECTION_TYPE_DEFAULT,
|
||||
)
|
||||
for (dir in metadata.getDirectoriesOfType(XmpDirectory::class.java)) {
|
||||
|
@ -767,6 +818,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
"QuickTime Sound",
|
||||
"QuickTime Video",
|
||||
)
|
||||
private val allMetadataMergeableDirNames = setOf(
|
||||
"Exif SubIFD",
|
||||
"GIF Control",
|
||||
"GIF Image",
|
||||
"HEIF",
|
||||
"ICC Profile",
|
||||
"IPTC",
|
||||
"WebP",
|
||||
"XMP",
|
||||
)
|
||||
|
||||
// catalog metadata
|
||||
private const val KEY_MIME_TYPE = "mimeType"
|
||||
|
|
|
@ -6,6 +6,7 @@ import android.os.Environment
|
|||
import android.os.storage.StorageManager
|
||||
import androidx.core.os.EnvironmentCompat
|
||||
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.StorageUtils.getPrimaryVolumePath
|
||||
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) }
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
|
||||
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
|
||||
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
|
||||
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>>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
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)))
|
||||
}
|
||||
|
||||
|
@ -114,7 +117,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
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))
|
||||
}
|
||||
|
||||
|
@ -155,6 +158,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
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 {
|
||||
const val CHANNEL = "deckers.thibault/aves/storage"
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
|||
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
|
||||
try {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
@ -79,7 +79,7 @@ class ThumbnailFetcher internal constructor(
|
|||
} else {
|
||||
var errorDetails: String? = exception?.message
|
||||
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)
|
||||
}
|
||||
|
@ -99,10 +99,10 @@ class ThumbnailFetcher internal constructor(
|
|||
val contentId = uri.tryParseId() ?: return null
|
||||
val resolver = context.contentResolver
|
||||
return if (isVideo(mimeType)) {
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
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
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
package deckers.thibault.aves.channel.streams
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
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.LogUtils
|
||||
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.isHeic
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
|
@ -26,10 +26,9 @@ import io.flutter.plugin.common.EventChannel.EventSink
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.IOException
|
||||
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 handler: Handler
|
||||
|
||||
|
@ -108,22 +107,22 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
|
||||
private fun streamImageAsIs(uri: Uri, mimeType: String) {
|
||||
try {
|
||||
StorageUtils.openInputStream(activity, uri)?.use { input -> streamBytes(input) }
|
||||
} catch (e: IOException) {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input -> streamBytes(input) }
|
||||
} catch (e: Exception) {
|
||||
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) {
|
||||
val model: Any = if (isHeic(mimeType) && pageId != null) {
|
||||
MultiTrackImage(activity, uri, pageId)
|
||||
MultiTrackImage(context, uri, pageId)
|
||||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(activity, uri, pageId)
|
||||
TiffImage(context, uri, pageId)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(uri, mimeType)
|
||||
}
|
||||
|
||||
val target = Glide.with(activity)
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(model)
|
||||
|
@ -132,7 +131,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
var bitmap = target.get()
|
||||
if (needRotationAfterGlide(mimeType)) {
|
||||
bitmap = applyExifOrientation(activity, bitmap, rotationDegrees, isFlipped)
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
success(bitmap.getBytes(MimeTypes.canHaveAlpha(mimeType), recycle = false))
|
||||
|
@ -142,15 +141,15 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
} catch (e: Exception) {
|
||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String) {
|
||||
val target = Glide.with(activity)
|
||||
val target = Glide.with(context)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(VideoThumbnail(activity, uri))
|
||||
.load(VideoThumbnail(context, uri))
|
||||
.submit()
|
||||
try {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
|
@ -163,14 +162,14 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
} catch (e: Exception) {
|
||||
error("streamImage-video-exception", "failed to get image for mimeType=$mimeType uri=$uri", e.message)
|
||||
} finally {
|
||||
Glide.with(activity).clear(target)
|
||||
Glide.with(context).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun toErrorDetails(e: Exception): String? {
|
||||
val errorDetails = e.message
|
||||
return if (errorDetails?.isNotEmpty() == true) {
|
||||
errorDetails.split("\n".toRegex(), 2).first()
|
||||
errorDetails.split(Regex("\n"), 2).first()
|
||||
} else {
|
||||
errorDetails
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Looper
|
|||
import android.util.Log
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
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.ImageProviderFactory.getProvider
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -28,7 +29,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
op = arguments["op"] as String?
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Suppress("unchecked_cast")
|
||||
val rawEntries = arguments["entries"] as List<FieldMap>?
|
||||
if (rawEntries != null) {
|
||||
entryMapList.addAll(rawEntries)
|
||||
|
@ -44,6 +45,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
|
||||
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
|
||||
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename() }
|
||||
else -> endOfStream()
|
||||
}
|
||||
}
|
||||
|
@ -98,12 +100,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
for (entryMap in entryMapList) {
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
if (uri != null) {
|
||||
val result = hashMapOf<String, Any?>(
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri != null && mimeType != null) {
|
||||
val result: FieldMap = hashMapOf(
|
||||
"uri" to uri.toString(),
|
||||
)
|
||||
try {
|
||||
provider.delete(activity, uri, path)
|
||||
provider.delete(activity, uri, path, mimeType)
|
||||
result["success"] = true
|
||||
} catch (e: Exception) {
|
||||
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?
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -138,7 +142,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
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 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?
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
@ -168,13 +173,41 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
|||
|
||||
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
|
||||
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 onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
|
||||
})
|
||||
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 {
|
||||
private val LOG_TAG = LogUtils.createTag<ImageOpStreamHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/media_op_stream"
|
||||
|
|
|
@ -21,7 +21,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
|
|||
|
||||
init {
|
||||
if (arguments is Map<*, *>) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Suppress("unchecked_cast")
|
||||
knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.streams
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
|
@ -39,7 +40,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
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() }
|
||||
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||
"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?
|
||||
if (path == null) {
|
||||
error("requestVolumeAccess-args", "failed because of missing arguments", null)
|
||||
error("requestDirectoryAccess-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
PermissionManager.requestVolumeAccess(activity, path, {
|
||||
PermissionManager.requestDirectoryAccess(activity, path, {
|
||||
success(true)
|
||||
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() {
|
||||
val name = args["name"] as String?
|
||||
val mimeType = args["mimeType"] as String?
|
||||
|
|
|
@ -2,9 +2,7 @@ package deckers.thibault.aves.metadata
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.io.File
|
||||
|
@ -15,7 +13,7 @@ import java.util.*
|
|||
import java.util.regex.Pattern
|
||||
|
||||
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)
|
||||
// Examples:
|
||||
|
@ -31,6 +29,7 @@ object Metadata {
|
|||
const val DIR_XMP = "XMP" // from metadata-extractor
|
||||
const val DIR_MEDIA = "Media" // custom
|
||||
const val DIR_COVER_ART = "Cover" // custom
|
||||
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
|
||||
|
||||
// types of metadata
|
||||
const val TYPE_EXIF = "exif"
|
||||
|
@ -135,7 +134,6 @@ object Metadata {
|
|||
} else {
|
||||
// make a preview from the beginning of the file,
|
||||
// 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]
|
||||
if (previewFile == null) {
|
||||
previewFile = createPreviewFile(context, uri)
|
||||
|
|
|
@ -1,15 +1,27 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import com.drew.lang.Rational
|
||||
import com.drew.lang.SequentialByteArrayReader
|
||||
import com.drew.metadata.Directory
|
||||
import com.drew.metadata.exif.ExifIFD0Directory
|
||||
import com.drew.metadata.iptc.IptcReader
|
||||
import com.drew.metadata.png.PngDirectory
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
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"
|
||||
private const val PNG_ZTXT_DIR_NAME = "PNG-zTXt"
|
||||
|
||||
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
|
||||
|
||||
fun Directory.getSafeString(tag: Int, save: (value: String) -> Unit) {
|
||||
|
@ -59,4 +71,45 @@ object MetadataExtractorHelper {
|
|||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -46,7 +46,7 @@ object MultiPage {
|
|||
val format = extractor.getTrackFormat(i)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let { 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_MIME_TYPE to trackMime,
|
||||
)
|
||||
|
@ -106,7 +106,7 @@ object MultiPage {
|
|||
val format = extractor.getTrackFormat(i)
|
||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||
if (MimeTypes.isVideo(mime)) {
|
||||
val track = hashMapOf<String, Any?>(
|
||||
val track: FieldMap = hashMapOf(
|
||||
KEY_PAGE to trackCount++,
|
||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
||||
KEY_IS_DEFAULT to false,
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import com.drew.imaging.ImageMetadataReader
|
|||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
|
@ -47,16 +48,16 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
return
|
||||
}
|
||||
|
||||
val map = hashMapOf<String, Any?>(
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"uri" to uri.toString(),
|
||||
"sourceMimeType" to mimeType,
|
||||
)
|
||||
try {
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) map["title"] = cursor.getString(it) }
|
||||
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) map["sizeBytes"] = cursor.getLong(it) }
|
||||
cursor.getColumnIndex(PATH).let { if (it != -1) map["path"] = 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) fields["sizeBytes"] = cursor.getLong(it) }
|
||||
cursor.getColumnIndex(PATH).let { if (it != -1) fields["path"] = cursor.getString(it) }
|
||||
cursor.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -64,7 +65,7 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
return
|
||||
}
|
||||
|
||||
val entry = SourceEntry(map).fillPreCatalogMetadata(context)
|
||||
val entry = SourceEntry(fields).fillPreCatalogMetadata(context)
|
||||
if (entry.isSized || entry.isSvg || entry.isVideo) {
|
||||
callback.onSuccess(entry.toMap())
|
||||
} else {
|
||||
|
@ -75,7 +76,7 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ContentImageProvider>()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
const val PATH = MediaStore.MediaColumns.DATA
|
||||
|
||||
private val projection = arrayOf(
|
||||
|
|
|
@ -2,10 +2,14 @@ package deckers.thibault.aves.model.provider
|
|||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.bumptech.glide.Glide
|
||||
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.ExifOrientationOp
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.utils.*
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditExif
|
||||
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
||||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
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.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
|
||||
abstract class ImageProvider {
|
||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
|
||||
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")
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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) {
|
||||
throw UnsupportedOperationException("`scanPostMetadataEdit` is not supported by this image provider")
|
||||
}
|
||||
|
@ -57,19 +64,26 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
suspend fun exportMultiple(
|
||||
context: Context,
|
||||
activity: Activity,
|
||||
imageExportMimeType: String,
|
||||
destinationDir: String,
|
||||
targetDir: String,
|
||||
entries: List<AvesEntry>,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
if (!supportedExportMimeTypes.contains(imageExportMimeType)) {
|
||||
throw Exception("unsupported export MIME type=$imageExportMimeType")
|
||||
callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType"))
|
||||
}
|
||||
|
||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -78,7 +92,7 @@ abstract class ImageProvider {
|
|||
val sourcePath = entry.path
|
||||
val pageId = entry.pageId
|
||||
|
||||
val result = hashMapOf<String, Any?>(
|
||||
val result: FieldMap = hashMapOf(
|
||||
"uri" to sourceUri.toString(),
|
||||
"pageId" to pageId,
|
||||
"success" to false,
|
||||
|
@ -88,16 +102,17 @@ abstract class ImageProvider {
|
|||
val exportMimeType = if (isVideo(sourceMimeType)) sourceMimeType else imageExportMimeType
|
||||
try {
|
||||
val newFields = exportSingleByTreeDocAndScan(
|
||||
context = context,
|
||||
activity = activity,
|
||||
sourceEntry = entry,
|
||||
destinationDir = destinationDir,
|
||||
destinationDirDocFile = destinationDirDocFile,
|
||||
targetDir = targetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
nameConflictStrategy = nameConflictStrategy,
|
||||
exportMimeType = exportMimeType,
|
||||
)
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
} 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)
|
||||
}
|
||||
|
@ -105,10 +120,11 @@ abstract class ImageProvider {
|
|||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun exportSingleByTreeDocAndScan(
|
||||
context: Context,
|
||||
activity: Activity,
|
||||
sourceEntry: AvesEntry,
|
||||
destinationDir: String,
|
||||
destinationDirDocFile: DocumentFileCompat,
|
||||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
exportMimeType: String,
|
||||
): FieldMap {
|
||||
val sourceMimeType = sourceEntry.mimeType
|
||||
|
@ -117,7 +133,7 @@ abstract class ImageProvider {
|
|||
|
||||
var desiredNameWithoutExtension = if (sourceEntry.path != null) {
|
||||
val sourceFileName = File(sourceEntry.path).name
|
||||
sourceFileName.replaceFirst("[.][^.]+$".toRegex(), "")
|
||||
sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
|
||||
} else {
|
||||
sourceUri.lastPathSegment!!
|
||||
}
|
||||
|
@ -125,23 +141,29 @@ abstract class ImageProvider {
|
|||
val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId
|
||||
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`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, availableNameWithoutExtension)
|
||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||
val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension)
|
||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||
|
||||
if (isVideo(sourceMimeType)) {
|
||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(context, sourceUri)
|
||||
sourceDocFile.copyTo(destinationDocFile)
|
||||
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||
sourceDocFile.copyTo(targetDocFile)
|
||||
} else {
|
||||
val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) {
|
||||
MultiTrackImage(context, sourceUri, pageId)
|
||||
MultiTrackImage(activity, sourceUri, pageId)
|
||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||
TiffImage(context, sourceUri, pageId)
|
||||
TiffImage(activity, sourceUri, pageId)
|
||||
} else {
|
||||
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
||||
}
|
||||
|
@ -152,7 +174,7 @@ abstract class ImageProvider {
|
|||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
|
||||
val target = Glide.with(context)
|
||||
val target = Glide.with(activity)
|
||||
.asBitmap()
|
||||
.apply(glideOptions)
|
||||
.load(model)
|
||||
|
@ -160,11 +182,11 @@ abstract class ImageProvider {
|
|||
try {
|
||||
var bitmap = target.get()
|
||||
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")
|
||||
|
||||
destinationDocFile.openOutputStream().use { output ->
|
||||
targetDocFile.openOutputStream().use { output ->
|
||||
if (exportMimeType == MimeTypes.BMP) {
|
||||
BmpWriter.writeRGB24(bitmap, output)
|
||||
} else {
|
||||
|
@ -179,7 +201,7 @@ abstract class ImageProvider {
|
|||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
else -> throw Exception("unsupported export MIME type=$exportMimeType")
|
||||
|
@ -187,45 +209,75 @@ abstract class ImageProvider {
|
|||
bitmap.compress(format, quality, output)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// remove empty file
|
||||
if (targetDocFile.exists()) {
|
||||
targetDocFile.delete()
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
Glide.with(context).clear(target)
|
||||
Glide.with(activity).clear(target)
|
||||
}
|
||||
}
|
||||
|
||||
val fileName = destinationDocFile.name
|
||||
val destinationFullPath = destinationDir + fileName
|
||||
val fileName = targetDocFile.name
|
||||
val targetFullPath = targetDir + fileName
|
||||
|
||||
return MediaStoreImageProvider().scanNewPath(context, destinationFullPath, exportMimeType)
|
||||
return MediaStoreImageProvider().scanNewPath(activity, targetFullPath, exportMimeType)
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun captureFrame(
|
||||
context: Context,
|
||||
activity: Activity,
|
||||
desiredNameWithoutExtension: String,
|
||||
exifFields: FieldMap,
|
||||
bytes: ByteArray,
|
||||
destinationDir: String,
|
||||
targetDir: String,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val destinationDirDocFile = createDirectoryIfAbsent(context, destinationDir)
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
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
|
||||
}
|
||||
|
||||
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`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, availableNameWithoutExtension)
|
||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(context, destinationTreeFile.uri)
|
||||
val targetTreeFile = targetDirDocFile.createFile(captureMimeType, targetNameWithoutExtension)
|
||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||
|
||||
try {
|
||||
if (exifFields.isEmpty()) {
|
||||
destinationDocFile.openOutputStream().use { output ->
|
||||
targetDocFile.openOutputStream().use { output ->
|
||||
output.write(bytes)
|
||||
}
|
||||
} else {
|
||||
|
@ -284,55 +336,56 @@ abstract class ImageProvider {
|
|||
exif.saveAttributes()
|
||||
|
||||
// copy the edited temporary file back to the original
|
||||
DocumentFileCompat.fromFile(editableFile).copyTo(destinationDocFile)
|
||||
DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile)
|
||||
}
|
||||
|
||||
val fileName = destinationDocFile.name
|
||||
val destinationFullPath = destinationDir + fileName
|
||||
val newFields = MediaStoreImageProvider().scanNewPath(context, destinationFullPath, captureMimeType)
|
||||
val fileName = targetDocFile.name
|
||||
val targetFullPath = targetDir + fileName
|
||||
val newFields = MediaStoreImageProvider().scanNewPath(activity, targetFullPath, captureMimeType)
|
||||
callback.onSuccess(newFields)
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findAvailableFileNameWithoutExtension(dir: String, desiredNameWithoutExtension: String, extension: String?): String {
|
||||
var nameWithoutExtension = desiredNameWithoutExtension
|
||||
var i = 0
|
||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
||||
i++
|
||||
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
||||
}
|
||||
return nameWithoutExtension
|
||||
}
|
||||
|
||||
suspend fun rename(context: Context, oldPath: String, oldMediaUri: Uri, mimeType: String, newFilename: String, callback: ImageOpCallback) {
|
||||
val oldFile = File(oldPath)
|
||||
val newFile = File(oldFile.parent, newFilename)
|
||||
if (oldFile == newFile) {
|
||||
Log.w(LOG_TAG, "new name and old name are the same, path=$oldPath")
|
||||
callback.onSuccess(HashMap())
|
||||
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
|
||||
// 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 i = 0
|
||||
while (File(dir, "$nameWithoutExtension$extension").exists()) {
|
||||
i++
|
||||
nameWithoutExtension = "$desiredNameWithoutExtension ($i)"
|
||||
}
|
||||
nameWithoutExtension
|
||||
}
|
||||
NameConflictStrategy.REPLACE -> {
|
||||
if (targetFile.exists()) {
|
||||
val path = targetFile.path
|
||||
MediaStoreImageProvider().apply {
|
||||
val uri = getContentUriForPath(activity, path)
|
||||
uri ?: throw Exception("failed to find content URI for path=$path")
|
||||
delete(activity, uri, path, mimeType)
|
||||
}
|
||||
}
|
||||
desiredNameWithoutExtension
|
||||
}
|
||||
NameConflictStrategy.SKIP -> {
|
||||
if (targetFile.exists()) {
|
||||
null
|
||||
} else {
|
||||
desiredNameWithoutExtension
|
||||
}
|
||||
}
|
||||
} catch (e: FileNotFoundException) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
var videoBytes: ByteArray? = null
|
||||
|
@ -381,7 +428,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
} else {
|
||||
// copy original file to a temporary file for editing
|
||||
originalDocumentFile.openInputStream().use { imageInput ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { imageInput ->
|
||||
imageInput.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
@ -401,7 +448,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
// 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)) {
|
||||
return false
|
||||
|
@ -428,18 +475,12 @@ abstract class ImageProvider {
|
|||
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 videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
val xmp = originalDocumentFile.openInputStream().use { input -> PixyMetaHelper.getXmp(input) }
|
||||
val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
|
||||
if (xmp == null) {
|
||||
callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri"))
|
||||
return false
|
||||
|
@ -447,7 +488,7 @@ abstract class ImageProvider {
|
|||
|
||||
outputStream().use { output ->
|
||||
// reopen input to read from start
|
||||
originalDocumentFile.openInputStream().use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
val editedXmpString = edit(xmp.xmpDocString())
|
||||
val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null
|
||||
PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString)
|
||||
|
@ -461,7 +502,7 @@ abstract class ImageProvider {
|
|||
|
||||
try {
|
||||
// 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)) {
|
||||
return false
|
||||
|
@ -476,7 +517,7 @@ abstract class ImageProvider {
|
|||
|
||||
// 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.
|
||||
// return whether the file at `path` is fine
|
||||
// returns whether the file at `path` is fine
|
||||
private fun checkTrailerOffset(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
@ -635,7 +676,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
|
@ -665,7 +700,7 @@ abstract class ImageProvider {
|
|||
try {
|
||||
outputStream().use { output ->
|
||||
// reopen input to read from start
|
||||
originalDocumentFile.openInputStream().use { input ->
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
PixyMetaHelper.removeMetadata(input, output, types)
|
||||
}
|
||||
}
|
||||
|
@ -678,7 +713,7 @@ abstract class ImageProvider {
|
|||
|
||||
try {
|
||||
// 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)) {
|
||||
return
|
||||
|
@ -692,6 +727,22 @@ abstract class ImageProvider {
|
|||
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 {
|
||||
fun onSuccess(fields: FieldMap)
|
||||
fun onFailure(throwable: Throwable)
|
||||
|
@ -700,6 +751,21 @@ abstract class ImageProvider {
|
|||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ImageProvider>()
|
||||
|
||||
val FILE_EXTENSION_PATTERN = Regex("[.][^.]+$")
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,25 +4,29 @@ import android.annotation.SuppressLint
|
|||
import android.app.Activity
|
||||
import android.app.RecoverableSecurityException
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.media.MediaScannerConnection
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
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.FieldMap
|
||||
import deckers.thibault.aves.model.NameConflictStrategy
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
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.isVideo
|
||||
import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent
|
||||
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
|
||||
import deckers.thibault.aves.utils.StorageUtils.getDocumentFile
|
||||
import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
@ -222,40 +226,64 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
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
|
||||
|
||||
// `uri` is a media URI, not a document URI
|
||||
override suspend fun delete(activity: Activity, uri: Uri, path: String?) {
|
||||
path ?: throw Exception("failed to delete file because path is null")
|
||||
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")
|
||||
if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) {
|
||||
Log.d(LOG_TAG, "delete document at uri=$uri path=$path")
|
||||
val df = StorageUtils.getDocumentFile(activity, path, uri)
|
||||
|
||||
if (File(path).exists() && requireAccessPermission(activity, path)) {
|
||||
// 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 if the app has the permission
|
||||
val df = getDocumentFile(activity, path, uri)
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
if (df != null && df.delete()) return
|
||||
throw Exception("failed to delete file with df=$df")
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
if (df != null && df.delete()) return
|
||||
throw Exception("failed to delete file with df=$df")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(LOG_TAG, "delete content at uri=$uri")
|
||||
if (activity.contentResolver.delete(uri, null, null) > 0) return
|
||||
} catch (securityException: SecurityException) {
|
||||
// even if the app has access permission granted on the containing directory,
|
||||
// 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
|
||||
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 intentSender = rse.userAction.actionIntent.intentSender
|
||||
|
||||
// request user permission for this item
|
||||
pendingDeleteCompleter = CompletableFuture<Boolean>()
|
||||
activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null)
|
||||
val granted = pendingDeleteCompleter!!.join()
|
||||
MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture<Boolean>()
|
||||
activity.startIntentSenderForResult(intentSender, DELETE_SINGLE_PERMISSION_REQUEST, null, 0, 0, 0, null)
|
||||
val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join()
|
||||
|
||||
pendingDeleteCompleter = null
|
||||
MainActivity.pendingScopedStoragePermissionCompleter = null
|
||||
if (granted) {
|
||||
delete(activity, uri, path)
|
||||
delete(activity, uri, path, mimeType)
|
||||
} else {
|
||||
throw Exception("failed to get delete permission")
|
||||
}
|
||||
|
@ -269,13 +297,14 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
override suspend fun moveMultiple(
|
||||
activity: Activity,
|
||||
copy: Boolean,
|
||||
destinationDir: String,
|
||||
targetDir: String,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
entries: List<AvesEntry>,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir)
|
||||
if (destinationDirDocFile == null) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$destinationDir"))
|
||||
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -284,7 +313,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val sourcePath = entry.path
|
||||
val mimeType = entry.mimeType
|
||||
|
||||
val result = hashMapOf<String, Any?>(
|
||||
val result: FieldMap = hashMapOf(
|
||||
"uri" to sourceUri.toString(),
|
||||
"success" to false,
|
||||
)
|
||||
|
@ -305,77 +334,272 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// - 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
|
||||
try {
|
||||
val newFields = moveSingleByTreeDocAndScan(
|
||||
activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy,
|
||||
val newFields = moveSingle(
|
||||
activity = activity,
|
||||
sourcePath = sourcePath,
|
||||
sourceUri = sourceUri,
|
||||
targetDir = targetDir,
|
||||
targetDirDocFile = targetDirDocFile,
|
||||
nameConflictStrategy = nameConflictStrategy,
|
||||
mimeType = mimeType,
|
||||
copy = copy,
|
||||
)
|
||||
result["newFields"] = newFields
|
||||
result["success"] = true
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun moveSingleByTreeDocAndScan(
|
||||
private suspend fun moveSingle(
|
||||
activity: Activity,
|
||||
sourcePath: String,
|
||||
sourceUri: Uri,
|
||||
destinationDir: String,
|
||||
destinationDirDocFile: DocumentFileCompat,
|
||||
targetDir: String,
|
||||
targetDirDocFile: DocumentFileCompat?,
|
||||
nameConflictStrategy: NameConflictStrategy,
|
||||
mimeType: String,
|
||||
copy: Boolean,
|
||||
): FieldMap {
|
||||
val sourceFile = File(sourcePath)
|
||||
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
|
||||
if (sourceDir == destinationDir) {
|
||||
if (copy) throw Exception("file at path=$sourcePath is already in destination directory")
|
||||
return HashMap<String, Any?>()
|
||||
val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) }
|
||||
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
|
||||
// nothing to do unless it's a renamed copy
|
||||
return skippedFieldMap
|
||||
}
|
||||
|
||||
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()) {
|
||||
throw Exception("file with name=$sourceFileName already exists in destination directory")
|
||||
}
|
||||
|
||||
// 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`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension)
|
||||
val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri)
|
||||
return moveSingleByTreeDoc(
|
||||
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 destinationDirDocFile URI as `targetParentDocumentUri`
|
||||
// when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri`
|
||||
val source = DocumentFileCompat.fromSingleUri(activity, sourceUri)
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
source.copyTo(destinationDocFile)
|
||||
|
||||
// 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*
|
||||
// - the original extension does not match the extension added by the underlying provider
|
||||
val fileName = destinationDocFile.name
|
||||
val destinationFullPath = destinationDir + fileName
|
||||
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`
|
||||
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||
// through a document URI, not a tree URI
|
||||
// note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension)
|
||||
val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri)
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
source.copyTo(targetDocFile)
|
||||
|
||||
// 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*
|
||||
// - the original extension does not match the extension added by the underlying provider
|
||||
val fileName = targetDocFile.name
|
||||
targetDir + fileName
|
||||
}
|
||||
|
||||
var deletedSource = false
|
||||
if (!copy) {
|
||||
// delete original entry
|
||||
try {
|
||||
delete(activity, sourceUri, sourcePath)
|
||||
deletedSource = true
|
||||
delete(activity, sourceUri, sourcePath, mimeType)
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
|
||||
}
|
||||
}
|
||||
|
||||
return scanNewPath(activity, destinationFullPath, mimeType).apply {
|
||||
put("deletedSource", deletedSource)
|
||||
return scanNewPath(activity, targetPath, mimeType)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -445,9 +669,9 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val contentId = newUri.tryParseId()
|
||||
if (contentId != null) {
|
||||
if (isImage(mimeType)) {
|
||||
contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId)
|
||||
contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
|
||||
} 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 {
|
||||
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
|
||||
|
||||
|
@ -494,8 +741,6 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
MediaStore.MediaColumns.ORIENTATION,
|
||||
) else emptyArray()
|
||||
)
|
||||
|
||||
var pendingDeleteCompleter: CompletableFuture<Boolean>? = null
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -513,7 +758,7 @@ object MediaColumns {
|
|||
@SuppressLint("InlinedApi")
|
||||
const val DURATION = MediaStore.MediaColumns.DURATION
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
const val PATH = MediaStore.MediaColumns.DATA
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,20 +1,20 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object LogUtils {
|
||||
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
|
||||
inline fun <reified T> createTag(): String {
|
||||
val kClass = T::class
|
||||
// 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) {
|
||||
// shorten class name to "a.b.CD"
|
||||
val simpleName = kClass.simpleName!!
|
||||
val shortSimpleName = simpleName.replace("[a-z]".toRegex(), "")
|
||||
val shortSimpleName = simpleName.replace(LOWER_CASE_PATTERN, "")
|
||||
logTag = logTag.replace(simpleName, shortSimpleName)
|
||||
if (logTag.length > LOG_TAG_MAX_LENGTH) {
|
||||
// shorten class name to "CD"
|
||||
|
|
|
@ -23,21 +23,34 @@ object MimeTypes {
|
|||
// raw raster
|
||||
private const val ARW = "image/x-sony-arw"
|
||||
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 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 NRW = "image/x-nikon-nrw"
|
||||
private const val ORF = "image/x-olympus-orf"
|
||||
private const val PEF = "image/x-pentax-pef"
|
||||
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 SR2 = "image/x-sony-sr2"
|
||||
private const val SRF = "image/x-sony-srf"
|
||||
private const val SRW = "image/x-samsung-srw"
|
||||
private const val X3F = "image/x-sigma-x3f"
|
||||
|
||||
// vector
|
||||
const val SVG = "image/svg+xml"
|
||||
|
||||
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 MOV = "video/quicktime"
|
||||
private const val MP2T = "video/mp2t"
|
||||
private const val MP2TS = "video/mp2ts"
|
||||
const val MP4 = "video/mp4"
|
||||
|
@ -125,16 +138,47 @@ object MimeTypes {
|
|||
// extensions
|
||||
|
||||
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
||||
ARW -> ".arw"
|
||||
AVI, AVI_VND -> ".avi"
|
||||
BMP -> ".bmp"
|
||||
CR2 -> ".cr2"
|
||||
CRW -> ".crw"
|
||||
DCR -> ".dcr"
|
||||
DJVU -> ".djvu"
|
||||
DNG -> ".dng"
|
||||
ERF -> ".erf"
|
||||
GIF -> ".gif"
|
||||
HEIC, HEIF -> ".heif"
|
||||
ICO -> ".ico"
|
||||
JPEG -> ".jpg"
|
||||
K25 -> ".k25"
|
||||
KDC -> ".kdc"
|
||||
MKV -> ".mkv"
|
||||
MOV -> ".mov"
|
||||
MP2T, MP2TS -> ".m2ts"
|
||||
MP4 -> ".mp4"
|
||||
MRW -> ".mrw"
|
||||
NEF -> ".nef"
|
||||
NRW -> ".nrw"
|
||||
OGV -> ".ogv"
|
||||
ORF -> ".orf"
|
||||
PEF -> ".pef"
|
||||
PNG -> ".png"
|
||||
PSD_VND, PSD_X -> ".psd"
|
||||
RAF -> ".raf"
|
||||
RAW -> ".raw"
|
||||
RW2 -> ".rw2"
|
||||
SR2 -> ".sr2"
|
||||
SRF -> ".srf"
|
||||
SRW -> ".srw"
|
||||
SVG -> ".svg"
|
||||
TIFF -> ".tiff"
|
||||
WBMP -> ".wbmp"
|
||||
WEBM -> ".webm"
|
||||
WEBP -> ".webp"
|
||||
X3F -> ".x3f"
|
||||
else -> null
|
||||
}
|
||||
|
||||
val tiffExtensionPattern = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
|
|
|
@ -3,24 +3,35 @@ package deckers.thibault.aves.utils
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.storage.StorageManager
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.StorageUtils.PathSegments
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import kotlin.collections.ArrayList
|
||||
|
||||
object 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)
|
||||
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")
|
||||
|
||||
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? {
|
||||
return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) }
|
||||
}
|
||||
|
@ -130,6 +170,18 @@ object PermissionManager {
|
|||
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)
|
||||
fun revokeDirectoryAccess(context: Context, path: String): Boolean {
|
||||
return StorageUtils.convertDirPathToTreeUri(context, path)?.let {
|
||||
|
|
|
@ -23,12 +23,16 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
|
|||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.*
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object 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
|
||||
*/
|
||||
|
@ -269,8 +273,8 @@ object StorageUtils {
|
|||
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
|
||||
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
|
||||
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? {
|
||||
val encoded = treeUri.toString().substring("content://com.android.externalstorage.documents/tree/".length)
|
||||
val matcher = Pattern.compile("(.*?):(.*)").matcher(Uri.decode(encoded))
|
||||
val encoded = treeUri.toString().substring(TREE_URI_ROOT.length)
|
||||
val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded))
|
||||
with(matcher) {
|
||||
if (find()) {
|
||||
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 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)
|
||||
return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
|
||||
|
@ -427,7 +431,14 @@ object StorageUtils {
|
|||
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
|
||||
// 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.
|
||||
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)) {
|
||||
// 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
|
||||
|
@ -439,7 +450,11 @@ object StorageUtils {
|
|||
else -> uri
|
||||
}
|
||||
}
|
||||
} else if (uri.userInfo != null) {
|
||||
// strip user info, if any
|
||||
return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
||||
}
|
||||
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
@ -448,11 +463,22 @@ object StorageUtils {
|
|||
val effectiveUri = getOriginalUri(context, uri)
|
||||
return try {
|
||||
context.contentResolver.openInputStream(effectiveUri)
|
||||
} catch (e: FileNotFoundException) {
|
||||
Log.w(LOG_TAG, "failed to find file at uri=$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 input stream for uri=$uri effectiveUri=$effectiveUri", e)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -467,7 +493,7 @@ object StorageUtils {
|
|||
}
|
||||
} catch (e: Exception) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
@ -482,7 +508,7 @@ object StorageUtils {
|
|||
class PathSegments(context: Context, fullPath: String) {
|
||||
var volumePath: String? = null // `volumePath` 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 {
|
||||
volumePath = getVolumePath(context, fullPath)
|
||||
|
|
26
android/app/src/main/res/drawable-v21/ic_notification.xml
Normal file
26
android/app/src/main/res/drawable-v21/ic_notification.xml
Normal 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>
|
9
android/app/src/main/res/drawable/ic_outline_stop_24.xml
Normal file
9
android/app/src/main/res/drawable/ic_outline_stop_24.xml
Normal 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>
|
|
@ -1,6 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<resources>
|
||||
<string name="app_name">아베스</string>
|
||||
<string name="search_shortcut_short_label">검색</string>
|
||||
<string name="videos_shortcut_short_label">동영상</string>
|
||||
<string name="app_name">아베스</string>
|
||||
<string name="search_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>
|
|
@ -3,4 +3,8 @@
|
|||
<string name="app_name">Aves</string>
|
||||
<string name="search_shortcut_short_label">Search</string>
|
||||
<string name="videos_shortcut_short_label">Videos</string>
|
||||
<string name="analysis_channel_name">Media scan</string>
|
||||
<string name="analysis_service_description">Scan images & videos</string>
|
||||
<string name="analysis_notification_default_title">Scanning media</string>
|
||||
<string name="analysis_notification_action_stop">Stop</string>
|
||||
</resources>
|
|
@ -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'}',
|
||||
];
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
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:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -39,7 +39,7 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
|
|||
|
||||
Future<ui.Codec> _loadAsync(AppIconImageKey key, DecoderCallback decode) async {
|
||||
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);
|
||||
} catch (error) {
|
||||
debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error');
|
||||
|
|
|
@ -62,8 +62,10 @@
|
|||
"@sourceStateLoading": {},
|
||||
"sourceStateCataloguing": "Cataloguing",
|
||||
"@sourceStateCataloguing": {},
|
||||
"sourceStateLocating": "Locating",
|
||||
"@sourceStateLocating": {},
|
||||
"sourceStateLocatingCountries": "Locating countries",
|
||||
"@sourceStateLocatingCountries": {},
|
||||
"sourceStateLocatingPlaces": "Locating places",
|
||||
"@sourceStateLocatingPlaces": {},
|
||||
|
||||
"chipActionDelete": "Delete",
|
||||
"@chipActionDelete": {},
|
||||
|
@ -159,6 +161,8 @@
|
|||
"@filterTypeMotionPhotoLabel": {},
|
||||
"filterTypePanoramaLabel": "Panorama",
|
||||
"@filterTypePanoramaLabel": {},
|
||||
"filterTypeRawLabel": "Raw",
|
||||
"@filterTypeRawLabel": {},
|
||||
"filterTypeSphericalVideoLabel": "360° Video",
|
||||
"@filterTypeSphericalVideoLabel": {},
|
||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||
|
@ -173,6 +177,11 @@
|
|||
"coordinateFormatDecimal": "Decimal degrees",
|
||||
"@coordinateFormatDecimal": {},
|
||||
|
||||
"unitSystemMetric": "Metric",
|
||||
"@unitSystemMetric": {},
|
||||
"unitSystemImperial": "Imperial",
|
||||
"@unitSystemImperial": {},
|
||||
|
||||
"videoLoopModeNever": "Never",
|
||||
"@videoLoopModeNever": {},
|
||||
"videoLoopModeShortOnly": "Short videos only",
|
||||
|
@ -193,6 +202,13 @@
|
|||
"mapStyleStamenWatercolor": "Stamen Watercolor",
|
||||
"@mapStyleStamenWatercolor": {},
|
||||
|
||||
"nameConflictStrategyRename": "Rename",
|
||||
"@nameConflictStrategyRename": {},
|
||||
"nameConflictStrategyReplace": "Replace",
|
||||
"@nameConflictStrategyReplace": {},
|
||||
"nameConflictStrategySkip": "Skip",
|
||||
"@nameConflictStrategySkip": {},
|
||||
|
||||
"keepScreenOnNever": "Never",
|
||||
"@keepScreenOnNever": {},
|
||||
"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": {},
|
||||
"addShortcutButtonLabel": "ADD",
|
||||
|
@ -327,6 +348,9 @@
|
|||
}
|
||||
},
|
||||
|
||||
"exportEntryDialogFormat": "Format:",
|
||||
"@exportEntryDialogFormat": {},
|
||||
|
||||
"renameEntryDialogLabel": "New name",
|
||||
"@renameEntryDialogLabel": {},
|
||||
|
||||
|
@ -555,6 +579,8 @@
|
|||
"@drawerCollectionMotionPhotos": {},
|
||||
"drawerCollectionPanoramas": "Panoramas",
|
||||
"@drawerCollectionPanoramas": {},
|
||||
"drawerCollectionRaws": "Raw photos",
|
||||
"@drawerCollectionRaws": {},
|
||||
"drawerCollectionSphericalVideos": "360° Videos",
|
||||
"@drawerCollectionSphericalVideos": {},
|
||||
|
||||
|
@ -688,8 +714,8 @@
|
|||
|
||||
"settingsSectionViewer": "Viewer",
|
||||
"@settingsSectionViewer": {},
|
||||
"settingsImageBackground": "Image background",
|
||||
"@settingsImageBackground": {},
|
||||
"settingsViewerShowOverlayOnOpening": "Show overlay on opening",
|
||||
"@settingsViewerShowOverlayOnOpening": {},
|
||||
"settingsViewerShowMinimap": "Show minimap",
|
||||
"@settingsViewerShowMinimap": {},
|
||||
"settingsViewerShowInformation": "Show information",
|
||||
|
@ -702,6 +728,8 @@
|
|||
"@settingsViewerEnableOverlayBlurEffect": {},
|
||||
"settingsViewerUseCutout": "Use cutout area",
|
||||
"@settingsViewerUseCutout": {},
|
||||
"settingsImageBackground": "Image background",
|
||||
"@settingsImageBackground": {},
|
||||
|
||||
"settingsViewerQuickActionsTile": "Quick actions",
|
||||
"@settingsViewerQuickActionsTile": {},
|
||||
|
@ -821,6 +849,10 @@
|
|||
"@settingsCoordinateFormatTile": {},
|
||||
"settingsCoordinateFormatTitle": "Coordinate Format",
|
||||
"@settingsCoordinateFormatTitle": {},
|
||||
"settingsUnitSystemTile": "Units",
|
||||
"@settingsUnitSystemTile": {},
|
||||
"settingsUnitSystemTitle": "Units",
|
||||
"@settingsUnitSystemTitle": {},
|
||||
|
||||
"statsPageTitle": "Stats",
|
||||
"@statsPageTitle": {},
|
||||
|
|
|
@ -27,7 +27,8 @@
|
|||
|
||||
"sourceStateLoading": "로딩 중",
|
||||
"sourceStateCataloguing": "분석 중",
|
||||
"sourceStateLocating": "장소 찾는 중",
|
||||
"sourceStateLocatingCountries": "국가 찾는 중",
|
||||
"sourceStateLocatingPlaces": "장소 찾는 중",
|
||||
|
||||
"chipActionDelete": "삭제",
|
||||
"chipActionGoToAlbumPage": "앨범 페이지에서 보기",
|
||||
|
@ -78,6 +79,7 @@
|
|||
"filterTypeAnimatedLabel": "애니메이션",
|
||||
"filterTypeMotionPhotoLabel": "모션 포토",
|
||||
"filterTypePanoramaLabel": "파노라마",
|
||||
"filterTypeRawLabel": "Raw",
|
||||
"filterTypeSphericalVideoLabel": "360° 동영상",
|
||||
"filterTypeGeotiffLabel": "GeoTIFF",
|
||||
"filterMimeImageLabel": "사진",
|
||||
|
@ -86,6 +88,9 @@
|
|||
"coordinateFormatDms": "도분초",
|
||||
"coordinateFormatDecimal": "소수점",
|
||||
|
||||
"unitSystemMetric": "미터법",
|
||||
"unitSystemImperial": "야드파운드법",
|
||||
|
||||
"videoLoopModeNever": "반복 안 함",
|
||||
"videoLoopModeShortOnly": "짧은 동영상만 반복",
|
||||
"videoLoopModeAlways": "항상 반복",
|
||||
|
@ -97,6 +102,10 @@
|
|||
"mapStyleStamenToner": "Stamen 토너",
|
||||
"mapStyleStamenWatercolor": "Stamen 수채화",
|
||||
|
||||
"nameConflictStrategyRename": "이름 변경",
|
||||
"nameConflictStrategyReplace": "대체",
|
||||
"nameConflictStrategySkip": "건너뛰기",
|
||||
|
||||
"keepScreenOnNever": "자동 꺼짐",
|
||||
"keepScreenOnViewerOnly": "뷰어 이용 시 작동",
|
||||
"keepScreenOnAlways": "항상 켜짐",
|
||||
|
@ -121,6 +130,9 @@
|
|||
"notEnoughSpaceDialogTitle": "저장공간 부족",
|
||||
"notEnoughSpaceDialogMessage": "“{volume}”에 필요 공간은 {neededSize}인데 사용 가능한 용량은 {freeSize}만 남아있습니다.",
|
||||
|
||||
"nameConflictDialogSingleSourceMessage": "이동할 폴더에 이름이 같은 파일이 있습니다.",
|
||||
"nameConflictDialogMultipleSourceMessage": "이름이 같은 파일이 있습니다.",
|
||||
|
||||
"addShortcutDialogLabel": "바로가기 라벨",
|
||||
"addShortcutButtonLabel": "추가",
|
||||
|
||||
|
@ -146,6 +158,8 @@
|
|||
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범의 항목 {count}개를 삭제하시겠습니까?}}",
|
||||
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
|
||||
|
||||
"exportEntryDialogFormat": "형식:",
|
||||
|
||||
"renameEntryDialogLabel": "이름",
|
||||
|
||||
"editEntryDateDialogTitle": "날짜 및 시간",
|
||||
|
@ -256,6 +270,7 @@
|
|||
"drawerCollectionVideos": "동영상",
|
||||
"drawerCollectionMotionPhotos": "모션 포토",
|
||||
"drawerCollectionPanoramas": "파노라마",
|
||||
"drawerCollectionRaws": "Raw 이미지",
|
||||
"drawerCollectionSphericalVideos": "360° 동영상",
|
||||
|
||||
"chipSortTitle": "정렬",
|
||||
|
@ -330,13 +345,14 @@
|
|||
"settingsCollectionSelectionQuickActionEditorBanner": "버튼을 길게 누른 후 이동하여 항목 선택할 때 표시될 버튼을 선택하세요.",
|
||||
|
||||
"settingsSectionViewer": "뷰어",
|
||||
"settingsImageBackground": "사진 배경",
|
||||
"settingsViewerShowOverlayOnOpening": "열릴 때 오버레이 표시",
|
||||
"settingsViewerShowMinimap": "미니맵 표시",
|
||||
"settingsViewerShowInformation": "상세 정보 표시",
|
||||
"settingsViewerShowInformationSubtitle": "제목, 날짜, 장소 등 표시",
|
||||
"settingsViewerShowShootingDetails": "촬영 정보 표시",
|
||||
"settingsViewerEnableOverlayBlurEffect": "오버레이 흐림 효과",
|
||||
"settingsViewerUseCutout": "컷아웃 영역 사용",
|
||||
"settingsImageBackground": "이미지 배경",
|
||||
|
||||
"settingsViewerQuickActionsTile": "빠른 작업",
|
||||
"settingsViewerQuickActionEditorTitle": "빠른 작업",
|
||||
|
@ -401,6 +417,8 @@
|
|||
"settingsLanguage": "언어",
|
||||
"settingsCoordinateFormatTile": "좌표 표현",
|
||||
"settingsCoordinateFormatTitle": "좌표 표현",
|
||||
"settingsUnitSystemTile": "단위법",
|
||||
"settingsUnitSystemTitle": "단위법",
|
||||
|
||||
"statsPageTitle": "통계",
|
||||
"statsImage": "{count, plural, other{사진}}",
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/geo/countries.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/enums.dart';
|
||||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/video/metadata.dart';
|
||||
import 'package:aves/ref/mime_types.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:country_code/country_code.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AvesEntry {
|
||||
|
@ -76,6 +76,7 @@ class AvesEntry {
|
|||
String? uri,
|
||||
String? path,
|
||||
int? contentId,
|
||||
String? title,
|
||||
int? dateModifiedSecs,
|
||||
List<AvesEntry>? burstEntries,
|
||||
}) {
|
||||
|
@ -90,7 +91,7 @@ class AvesEntry {
|
|||
height: height,
|
||||
sourceRotationDegrees: sourceRotationDegrees,
|
||||
sizeBytes: sizeBytes,
|
||||
sourceTitle: sourceTitle,
|
||||
sourceTitle: title ?? sourceTitle,
|
||||
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
|
||||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
|
@ -160,6 +161,7 @@ class AvesEntry {
|
|||
|
||||
String? get path => _path;
|
||||
|
||||
// directory path, without the trailing separator
|
||||
String? get directory {
|
||||
_directory ??= path != null ? pContext.dirname(path!) : null;
|
||||
return _directory;
|
||||
|
@ -170,11 +172,14 @@ class AvesEntry {
|
|||
return _filename;
|
||||
}
|
||||
|
||||
// file extension, including the `.`
|
||||
String? get extension {
|
||||
_extension ??= path != null ? pContext.extension(path!) : null;
|
||||
return _extension;
|
||||
}
|
||||
|
||||
bool get isMissingAtPath => path != null && !File(path!).existsSync();
|
||||
|
||||
// the MIME type reported by the Media Store is unreliable
|
||||
// so we use the one found during cataloguing if possible
|
||||
String get mimeType => _catalogMetadata?.mimeType ?? sourceMimeType;
|
||||
|
@ -420,7 +425,7 @@ class AvesEntry {
|
|||
_xmpSubjects = null;
|
||||
metadataChangeNotifier.notifyListeners();
|
||||
|
||||
_onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
_onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
}
|
||||
|
||||
void clearMetadata() {
|
||||
|
@ -428,17 +433,18 @@ class AvesEntry {
|
|||
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 (isSvg) {
|
||||
// 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
|
||||
final size = await SvgMetadataService.getSize(this);
|
||||
if (size != null) {
|
||||
await _applyNewFields({
|
||||
final fields = {
|
||||
'width': size.width.ceil(),
|
||||
'height': size.height.ceil(),
|
||||
}, persist: persist);
|
||||
};
|
||||
await _applyNewFields(fields, persist: persist);
|
||||
}
|
||||
catalogMetadata = CatalogMetadata(contentId: contentId);
|
||||
} else {
|
||||
|
@ -462,17 +468,17 @@ class AvesEntry {
|
|||
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;
|
||||
await _locateCountry();
|
||||
await _locateCountry(force: force);
|
||||
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
|
||||
Future<void> _locateCountry() async {
|
||||
if (!hasGps || hasAddress) return;
|
||||
Future<void> _locateCountry({required bool force}) async {
|
||||
if (!hasGps || (hasAddress && !force)) return;
|
||||
final countryCode = await countryTopology.countryCode(latLng!);
|
||||
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
|
||||
Future<void> locatePlace({required bool background}) async {
|
||||
if (!hasGps || hasFineAddress) return;
|
||||
Future<void> locatePlace({required bool background, required bool force, required Locale geocoderLocale}) async {
|
||||
if (!hasGps || (hasFineAddress && !force)) return;
|
||||
try {
|
||||
Future<List<Address>> call() => GeocodingService.getAddress(latLng!, geocoderLocale);
|
||||
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;
|
||||
|
||||
try {
|
||||
|
@ -558,6 +557,10 @@ class AvesEntry {
|
|||
}.any((s) => s != null && s.toUpperCase().contains(query));
|
||||
|
||||
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'];
|
||||
if (uri is String) this.uri = uri;
|
||||
final path = newFields['path'];
|
||||
|
@ -593,10 +596,11 @@ class AvesEntry {
|
|||
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
|
||||
}
|
||||
|
||||
await _onVisualFieldChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
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;
|
||||
_addressDetails = null;
|
||||
_bestDate = null;
|
||||
|
@ -609,8 +613,8 @@ class AvesEntry {
|
|||
final updated = await mediaFileService.getEntry(uri, mimeType);
|
||||
if (updated != null) {
|
||||
await _applyNewFields(updated.toMap(), persist: persist);
|
||||
await catalog(background: false, persist: persist);
|
||||
await locate(background: false);
|
||||
await catalog(background: background, persist: persist, force: force);
|
||||
await locate(background: background, force: force, geocoderLocale: geocoderLocale);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -618,11 +622,7 @@ class AvesEntry {
|
|||
final newFields = await metadataEditService.rotate(this, clockwise: clockwise);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(newFields, persist: persist);
|
||||
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -630,11 +630,7 @@ class AvesEntry {
|
|||
final newFields = await metadataEditService.flip(this);
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
final oldDateModifiedSecs = dateModifiedSecs;
|
||||
final oldRotationDegrees = rotationDegrees;
|
||||
final oldIsFlipped = isFlipped;
|
||||
await _applyNewFields(newFields, persist: persist);
|
||||
await _onImageChanged(oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -663,7 +659,7 @@ class AvesEntry {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
await EntryCache.evict(uri, mimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
|
||||
imageChangeNotifier.notifyListeners();
|
||||
|
|
63
lib/model/filters/coordinate.dart
Normal file
63
lib/model/filters/coordinate.dart
Normal 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';
|
||||
}
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
|
||||
import 'package:aves/model/entry.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/location.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
|
@ -24,6 +25,7 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
|
|||
TypeFilter.type,
|
||||
AlbumFilter.type,
|
||||
LocationFilter.type,
|
||||
CoordinateFilter.type,
|
||||
TagFilter.type,
|
||||
PathFilter.type,
|
||||
];
|
||||
|
@ -35,20 +37,22 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
|
|||
switch (type) {
|
||||
case AlbumFilter.type:
|
||||
return AlbumFilter.fromMap(jsonMap);
|
||||
case CoordinateFilter.type:
|
||||
return CoordinateFilter.fromMap(jsonMap);
|
||||
case FavouriteFilter.type:
|
||||
return FavouriteFilter.instance;
|
||||
case LocationFilter.type:
|
||||
return LocationFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
return TypeFilter.fromMap(jsonMap);
|
||||
case MimeFilter.type:
|
||||
return MimeFilter.fromMap(jsonMap);
|
||||
case PathFilter.type:
|
||||
return PathFilter.fromMap(jsonMap);
|
||||
case QueryFilter.type:
|
||||
return QueryFilter.fromMap(jsonMap);
|
||||
case TagFilter.type:
|
||||
return TagFilter.fromMap(jsonMap);
|
||||
case PathFilter.type:
|
||||
return PathFilter.fromMap(jsonMap);
|
||||
case TypeFilter.type:
|
||||
return TypeFilter.fromMap(jsonMap);
|
||||
}
|
||||
}
|
||||
debugPrint('failed to parse filter from json=$jsonString');
|
||||
|
|
|
@ -1,14 +1,19 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
||||
class PathFilter extends CollectionFilter {
|
||||
static const type = 'path';
|
||||
|
||||
// including trailing separator
|
||||
final String path;
|
||||
|
||||
// without trailing separator
|
||||
final String _rootAlbum;
|
||||
|
||||
@override
|
||||
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)
|
||||
: this(
|
||||
|
@ -22,7 +27,12 @@ class PathFilter extends CollectionFilter {
|
|||
};
|
||||
|
||||
@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
|
||||
String get universalLabel => path;
|
||||
|
|
|
@ -10,6 +10,7 @@ class TypeFilter extends CollectionFilter {
|
|||
static const _geotiff = 'geotiff'; // subset of `image/tiff`
|
||||
static const _motionPhoto = 'motion_photo'; // subset of `image/jpeg`
|
||||
static const _panorama = 'panorama'; // subset of images
|
||||
static const _raw = 'raw'; // specific image formats
|
||||
static const _sphericalVideo = 'spherical_video'; // subset of videos
|
||||
|
||||
final String itemType;
|
||||
|
@ -20,6 +21,7 @@ class TypeFilter extends CollectionFilter {
|
|||
static final geotiff = TypeFilter._private(_geotiff);
|
||||
static final motionPhoto = TypeFilter._private(_motionPhoto);
|
||||
static final panorama = TypeFilter._private(_panorama);
|
||||
static final raw = TypeFilter._private(_raw);
|
||||
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
|
||||
|
||||
@override
|
||||
|
@ -43,6 +45,10 @@ class TypeFilter extends CollectionFilter {
|
|||
_test = (entry) => entry.isImage && entry.is360;
|
||||
_icon = AIcons.threeSixty;
|
||||
break;
|
||||
case _raw:
|
||||
_test = (entry) => entry.isRaw;
|
||||
_icon = AIcons.raw;
|
||||
break;
|
||||
case _sphericalVideo:
|
||||
_test = (entry) => entry.isVideo && entry.is360;
|
||||
_icon = AIcons.threeSixty;
|
||||
|
@ -76,6 +82,8 @@ class TypeFilter extends CollectionFilter {
|
|||
return context.l10n.filterTypeMotionPhotoLabel;
|
||||
case _panorama:
|
||||
return context.l10n.filterTypePanoramaLabel;
|
||||
case _raw:
|
||||
return context.l10n.filterTypeRawLabel;
|
||||
case _sphericalVideo:
|
||||
return context.l10n.filterTypeSphericalVideoLabel;
|
||||
case _geotiff:
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class AddressDetails {
|
||||
class AddressDetails extends Equatable {
|
||||
final int? contentId;
|
||||
final String? countryCode, countryName, adminArea, locality;
|
||||
|
||||
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
|
||||
|
||||
const AddressDetails({
|
||||
this.contentId,
|
||||
this.countryCode,
|
||||
|
@ -45,7 +49,4 @@ class AddressDetails {
|
|||
'adminArea': adminArea,
|
||||
'locality': locality,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
|
||||
}
|
||||
|
|
|
@ -98,7 +98,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<void> init() async {
|
||||
debugPrint('$runtimeType init');
|
||||
_database = openDatabase(
|
||||
await path,
|
||||
onCreate: (db, version) async {
|
||||
|
@ -171,7 +170,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
Future<void> removeIds(Set<int> contentIds, {required bool metadataOnly}) async {
|
||||
if (contentIds.isEmpty) return;
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = db.batch();
|
||||
|
@ -188,7 +186,6 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
});
|
||||
await batch.commit(noResult: true);
|
||||
// debugPrint('$runtimeType removeIds complete in ${stopwatch.elapsed.inMilliseconds}ms for ${contentIds.length} entries');
|
||||
}
|
||||
|
||||
// entries
|
||||
|
@ -202,11 +199,9 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final db = await _database;
|
||||
final maps = await db.query(entryTable);
|
||||
final entries = maps.map((map) => AvesEntry.fromMap(map)).toSet();
|
||||
// debugPrint('$runtimeType loadEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
|
|
@ -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:flutter/widgets.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) {
|
||||
case CoordinateFormat.dms:
|
||||
return toDMS(latLng).join(', ');
|
||||
return GeoUtils.toDMS(latLng, minuteSecondPadding: minuteSecondPadding, secondDecimals: dmsSecondDecimals).join(', ');
|
||||
case CoordinateFormat.decimal:
|
||||
return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import 'package:flutter/material.dart';
|
|||
class SettingsDefaults {
|
||||
// app
|
||||
static const hasAcceptedTerms = false;
|
||||
static const canUseAnalysisService = true;
|
||||
static const isErrorReportingEnabled = false;
|
||||
static const mustBackTwiceToExit = true;
|
||||
static const keepScreenOn = KeepScreenOn.viewerOnly;
|
||||
|
@ -54,6 +55,7 @@ class SettingsDefaults {
|
|||
EntryAction.share,
|
||||
EntryAction.rotateScreen,
|
||||
];
|
||||
static const showOverlayOnOpening = true;
|
||||
static const showOverlayMinimap = false;
|
||||
static const showOverlayInfo = true;
|
||||
static const showOverlayShootingDetails = false;
|
||||
|
@ -81,6 +83,7 @@ class SettingsDefaults {
|
|||
static const infoMapStyle = EntryMapStyle.stamenWatercolor; // `infoMapStyle` has a contextual default value
|
||||
static const infoMapZoom = 12.0;
|
||||
static const coordinateFormat = CoordinateFormat.dms;
|
||||
static const unitSystem = UnitSystem.metric;
|
||||
|
||||
// rendering
|
||||
static const imageBackground = EntryBackground.white;
|
||||
|
|
|
@ -13,4 +13,6 @@ enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenTo
|
|||
|
||||
enum KeepScreenOn { never, viewerOnly, always }
|
||||
|
||||
enum UnitSystem { metric, imperial }
|
||||
|
||||
enum VideoLoopMode { never, shortOnly, always }
|
||||
|
|
|
@ -15,6 +15,7 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
final Settings settings = Settings._private();
|
||||
|
@ -27,9 +28,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
Settings._private() {
|
||||
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
||||
}
|
||||
Settings._private();
|
||||
|
||||
static const Set<String> internalKeys = {
|
||||
hasAcceptedTermsKey,
|
||||
|
@ -41,6 +40,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// app
|
||||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||
static const canUseAnalysisServiceKey = 'can_use_analysis_service';
|
||||
static const isErrorReportingEnabledKey = 'is_crashlytics_enabled';
|
||||
static const localeKey = 'locale';
|
||||
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
|
||||
|
@ -73,6 +73,7 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
// viewer
|
||||
static const viewerQuickActionsKey = 'viewer_quick_actions';
|
||||
static const showOverlayOnOpeningKey = 'show_overlay_on_opening';
|
||||
static const showOverlayMinimapKey = 'show_overlay_minimap';
|
||||
static const showOverlayInfoKey = 'show_overlay_info';
|
||||
static const showOverlayShootingDetailsKey = 'show_overlay_shooting_details';
|
||||
|
@ -97,6 +98,7 @@ class Settings extends ChangeNotifier {
|
|||
static const infoMapStyleKey = 'info_map_style';
|
||||
static const infoMapZoomKey = 'info_map_zoom';
|
||||
static const coordinateFormatKey = 'coordinates_format';
|
||||
static const unitSystemKey = 'unit_system';
|
||||
|
||||
// rendering
|
||||
static const imageBackgroundKey = 'image_background';
|
||||
|
@ -122,12 +124,16 @@ class Settings extends ChangeNotifier {
|
|||
bool get initialized => _prefs != null;
|
||||
|
||||
Future<void> init({
|
||||
required bool monitorPlatformSettings,
|
||||
bool isRotationLocked = false,
|
||||
bool areAnimationsRemoved = false,
|
||||
}) async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
_isRotationLocked = isRotationLocked;
|
||||
_areAnimationsRemoved = areAnimationsRemoved;
|
||||
if (monitorPlatformSettings) {
|
||||
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reset({required bool includeInternalKeys}) async {
|
||||
|
@ -141,7 +147,7 @@ class Settings extends ChangeNotifier {
|
|||
Future<void> setContextualDefaults() async {
|
||||
// performance
|
||||
final performanceClass = await deviceService.getPerformanceClass();
|
||||
enableOverlayBlurEffect = performanceClass >= 30;
|
||||
enableOverlayBlurEffect = performanceClass >= 29;
|
||||
|
||||
// availability
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
|
@ -163,6 +169,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
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);
|
||||
|
||||
set isErrorReportingEnabled(bool newValue) => setAndNotify(isErrorReportingEnabledKey, newValue);
|
||||
|
@ -193,6 +203,17 @@ class Settings extends ChangeNotifier {
|
|||
].join(localeSeparator);
|
||||
}
|
||||
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);
|
||||
|
@ -296,6 +317,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
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);
|
||||
|
||||
set showOverlayMinimap(bool newValue) => setAndNotify(showOverlayMinimapKey, newValue);
|
||||
|
@ -374,6 +399,10 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
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
|
||||
|
||||
EntryBackground get imageBackground => getEnumOrDefault(imageBackgroundKey, SettingsDefaults.imageBackground, EntryBackground.values);
|
||||
|
@ -540,6 +569,7 @@ class Settings extends ChangeNotifier {
|
|||
case showThumbnailMotionPhotoKey:
|
||||
case showThumbnailRawKey:
|
||||
case showThumbnailVideoDurationKey:
|
||||
case showOverlayOnOpeningKey:
|
||||
case showOverlayMinimapKey:
|
||||
case showOverlayInfoKey:
|
||||
case showOverlayShootingDetailsKey:
|
||||
|
@ -568,6 +598,7 @@ class Settings extends ChangeNotifier {
|
|||
case subtitleTextAlignmentKey:
|
||||
case infoMapStyleKey:
|
||||
case coordinateFormatKey:
|
||||
case unitSystemKey:
|
||||
case imageBackgroundKey:
|
||||
case accessibilityAnimationsKey:
|
||||
case timeToTakeActionKey:
|
||||
|
|
15
lib/model/settings/unit_system.dart
Normal file
15
lib/model/settings/unit_system.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
16
lib/model/source/analysis_controller.dart
Normal file
16
lib/model/source/analysis_controller.dart
Normal 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;
|
||||
}
|
|
@ -46,8 +46,8 @@ class CollectionLens with ChangeNotifier {
|
|||
id ??= hashCode;
|
||||
if (listenToSource) {
|
||||
final sourceEvents = source.eventBus;
|
||||
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => onEntryAdded(e.entries)));
|
||||
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => onEntryRemoved(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<EntryMovedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
|
||||
_subscriptions.add(sourceEvents.on<FilterVisibilityChangedEvent>().listen((e) => _refresh()));
|
||||
|
@ -73,6 +73,20 @@ class CollectionLens with ChangeNotifier {
|
|||
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;
|
||||
|
||||
int get entryCount => _filteredSortedEntries.length;
|
||||
|
@ -103,16 +117,16 @@ class CollectionLens with ChangeNotifier {
|
|||
filters.removeWhere((old) => old.category == filter.category);
|
||||
}
|
||||
filters.add(filter);
|
||||
onFilterChanged();
|
||||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void removeFilter(CollectionFilter filter) {
|
||||
if (!filters.contains(filter)) return;
|
||||
filters.remove(filter);
|
||||
onFilterChanged();
|
||||
_onFilterChanged();
|
||||
}
|
||||
|
||||
void onFilterChanged() {
|
||||
void _onFilterChanged() {
|
||||
_refresh();
|
||||
filterChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
@ -229,11 +243,11 @@ class CollectionLens with ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
void onEntryAdded(Set<AvesEntry>? entries) {
|
||||
void _onEntryAdded(Set<AvesEntry>? entries) {
|
||||
_refresh();
|
||||
}
|
||||
|
||||
void onEntryRemoved(Set<AvesEntry> entries) {
|
||||
void _onEntryRemoved(Set<AvesEntry> entries) {
|
||||
if (groupBursts) {
|
||||
// find impacted burst groups
|
||||
final obsoleteBurstEntries = <AvesEntry>{};
|
||||
|
@ -256,6 +270,7 @@ class CollectionLens with ChangeNotifier {
|
|||
// we should remove obsolete entries and sections
|
||||
// but do not apply sort/section
|
||||
// as section order change would surprise the user while browsing
|
||||
fixedSelection?.removeWhere(entries.contains);
|
||||
_filteredSortedEntries.removeWhere(entries.contains);
|
||||
_sortedEntries?.removeWhere(entries.contains);
|
||||
sections.forEach((key, sectionEntries) => sectionEntries.removeWhere(entries.contains));
|
||||
|
|
|
@ -9,9 +9,11 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/settings/settings.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/location.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/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -29,11 +31,9 @@ mixin SourceBase {
|
|||
|
||||
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}) => _progressStreamController.add(ProgressEvent(done: done, total: total));
|
||||
void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Future<void> loadDates() async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
_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) {
|
||||
|
@ -88,6 +86,15 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
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) {
|
||||
if (entries.isEmpty) return;
|
||||
|
||||
|
@ -115,11 +122,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
entries.forEach((v) => _entryById.remove(v.contentId));
|
||||
_rawEntries.removeAll(entries);
|
||||
_invalidate(entries);
|
||||
|
||||
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
|
||||
updateLocations();
|
||||
updateTags();
|
||||
updateDerivedFilters(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 {
|
||||
if (newName == entry.filenameWithoutExtension) return true;
|
||||
final newFields = await mediaFileService.rename(entry, '$newName${entry.extension}');
|
||||
if (newFields.isEmpty) return false;
|
||||
|
||||
await _moveEntry(entry, newFields, persist: persist);
|
||||
entry.metadataChangeNotifier.notifyListeners();
|
||||
eventBus.fire(EntryMovedEvent({entry}));
|
||||
return true;
|
||||
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);
|
||||
entry.metadataChangeNotifier.notifyListeners();
|
||||
eventBus.fire(EntryMovedEvent({entry}));
|
||||
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 {
|
||||
|
@ -215,6 +239,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
uri: newFields['uri'] as String?,
|
||||
path: newFields['path'] as String?,
|
||||
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?,
|
||||
));
|
||||
}
|
||||
|
@ -252,18 +278,52 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
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 {
|
||||
await Future.forEach<AvesEntry>(entries, (entry) => entry.refresh(persist: true));
|
||||
Future<void> refreshEntry(AvesEntry entry) async {
|
||||
await entry.refresh(background: false, persist: true, force: true, geocoderLocale: settings.appliedLocale);
|
||||
updateDerivedFilters({entry});
|
||||
eventBus.fire(EntryRefreshedEvent({entry}));
|
||||
}
|
||||
|
||||
_invalidate(entries);
|
||||
updateLocations();
|
||||
updateTags();
|
||||
|
||||
eventBus.fire(EntryRefreshedEvent(entries));
|
||||
Future<void> analyze(AnalysisController? analysisController, {Set<AvesEntry>? entries}) async {
|
||||
final todoEntries = entries ?? visibleEntries;
|
||||
final _analysisController = analysisController ?? AnalysisController();
|
||||
final force = _analysisController.force;
|
||||
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
|
||||
|
@ -310,46 +370,45 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
|
||||
}
|
||||
settings.hiddenFilters = hiddenFilters;
|
||||
|
||||
_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();
|
||||
|
||||
updateDerivedFilters();
|
||||
eventBus.fire(FilterVisibilityChangedEvent(filters, 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 {
|
||||
final Set<AvesEntry>? entries;
|
||||
|
||||
const EntryAddedEvent([this.entries]);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryRemovedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryRemovedEvent(this.entries);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryMovedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryMovedEvent(this.entries);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EntryRefreshedEvent {
|
||||
final Set<AvesEntry> entries;
|
||||
|
||||
const EntryRefreshedEvent(this.entries);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class FilterVisibilityChangedEvent {
|
||||
final Set<CollectionFilter> filters;
|
||||
final bool visible;
|
||||
|
@ -357,6 +416,7 @@ class FilterVisibilityChangedEvent {
|
|||
const FilterVisibilityChangedEvent(this.filters, this.visible);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class ProgressEvent {
|
||||
final int done, total;
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
enum SourceState { loading, cataloguing, locating, ready }
|
||||
enum SourceState { loading, cataloguing, locatingCountries, locatingPlaces, ready }
|
||||
|
||||
enum ChipSortFactor { date, name, count }
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ import 'package:aves/geo/countries.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/location.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/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -12,38 +14,43 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:tuple/tuple.dart';
|
||||
|
||||
mixin LocationMixin on SourceBase {
|
||||
static const _commitCountThreshold = 50;
|
||||
static const commitCountThreshold = 200;
|
||||
static const _stopCheckCountThreshold = 50;
|
||||
|
||||
List<String> sortedCountries = List.unmodifiable([]);
|
||||
List<String> sortedPlaces = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadAddresses() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final saved = await metadataDb.loadAddresses();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
|
||||
// debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
|
||||
Future<void> locateEntries() async {
|
||||
await _locateCountries();
|
||||
await _locatePlaces();
|
||||
Future<void> locateEntries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
|
||||
await _locateCountries(controller, candidateEntries);
|
||||
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
|
||||
Future<void> _locateCountries() async {
|
||||
final todo = visibleEntries.where((entry) => entry.hasGps && !entry.hasAddress).toSet();
|
||||
Future<void> _locateCountries(AnalysisController controller, Set<AvesEntry> candidateEntries) async {
|
||||
if (controller.isStopping) return;
|
||||
|
||||
final force = controller.force;
|
||||
final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locateCountriesTest)).toSet();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
stateNotifier.value = SourceState.locatingCountries;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final countryCodeMap = await countryTopology.countryCodeMap(todo.map((entry) => entry.latLng!).toSet());
|
||||
final newAddresses = <AddressDetails>[];
|
||||
final newAddresses = <AddressDetails>{};
|
||||
todo.forEach((entry) {
|
||||
final position = entry.latLng;
|
||||
final countryCode = countryCodeMap.entries.firstWhereOrNull((kv) => kv.value.contains(position))?.key;
|
||||
|
@ -54,19 +61,18 @@ mixin LocationMixin on SourceBase {
|
|||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locateCountries complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final byLocated = groupBy<AvesEntry, bool>(visibleEntries.where((entry) => entry.hasGps), (entry) => entry.hasFineAddress);
|
||||
final todo = byLocated[false] ?? [];
|
||||
final force = controller.force;
|
||||
final todo = (force ? candidateEntries.where((entry) => entry.hasGps) : candidateEntries.where(locatePlacesTest)).toSet();
|
||||
if (todo.isEmpty) return;
|
||||
|
||||
// geocoder calls take between 150ms and 250ms
|
||||
|
@ -81,47 +87,53 @@ mixin LocationMixin on SourceBase {
|
|||
final latLngFactor = pow(10, 2);
|
||||
Tuple2<int, int> approximateLatLng(AvesEntry entry) {
|
||||
// entry has coordinates
|
||||
final lat = entry.catalogMetadata!.latitude!;
|
||||
final lng = entry.catalogMetadata!.longitude!;
|
||||
final catalogMetadata = entry.catalogMetadata!;
|
||||
final lat = catalogMetadata.latitude!;
|
||||
final lng = catalogMetadata.longitude!;
|
||||
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?>{};
|
||||
byLocated[true]?.forEach((entry) {
|
||||
located.forEach((entry) {
|
||||
knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails);
|
||||
});
|
||||
|
||||
stateNotifier.value = SourceState.locating;
|
||||
stateNotifier.value = SourceState.locatingPlaces;
|
||||
var progressDone = 0;
|
||||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newAddresses = <AddressDetails>[];
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
var stopCheckCount = 0;
|
||||
final newAddresses = <AddressDetails>{};
|
||||
for (final entry in todo) {
|
||||
final latLng = approximateLatLng(entry);
|
||||
if (knownLocations.containsKey(latLng)) {
|
||||
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
|
||||
} 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,
|
||||
// so that we skip geocoding of following entries with the same coordinates
|
||||
knownLocations[latLng] = entry.addressDetails;
|
||||
}
|
||||
if (entry.hasFineAddress) {
|
||||
newAddresses.add(entry.addressDetails!);
|
||||
if (newAddresses.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
if (newAddresses.length >= commitCountThreshold) {
|
||||
await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
newAddresses.clear();
|
||||
}
|
||||
if (++stopCheckCount >= _stopCheckCountThreshold) {
|
||||
stopCheckCount = 0;
|
||||
if (controller.isStopping) return;
|
||||
}
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
}
|
||||
if (newAddresses.isNotEmpty) {
|
||||
await metadataDb.saveAddresses(Set.of(newAddresses));
|
||||
await metadataDb.saveAddresses(Set.unmodifiable(newAddresses));
|
||||
onAddressMetadataChanged();
|
||||
}
|
||||
// debugPrint('$runtimeType _locatePlaces complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
}
|
||||
|
||||
void onAddressMetadataChanged() {
|
||||
|
@ -142,9 +154,15 @@ mixin LocationMixin on SourceBase {
|
|||
// so we merge countries by code, keeping only one name for each code
|
||||
final countriesByCode = Map.fromEntries(locations.map((address) {
|
||||
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());
|
||||
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)) {
|
||||
sortedCountries = List.unmodifiable(updatedCountries);
|
||||
invalidateCountryFilterSummary();
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/covers.dart';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/favourites.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/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -38,11 +39,11 @@ class MediaStoreSource extends CollectionSource {
|
|||
}
|
||||
await loadDates();
|
||||
_initialized = true;
|
||||
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
|
||||
debugPrint('$runtimeType init complete in ${stopwatch.elapsed.inMilliseconds}ms');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
Future<void> refresh({AnalysisController? analysisController}) async {
|
||||
assert(_initialized);
|
||||
debugPrint('$runtimeType refresh start');
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
@ -59,10 +60,10 @@ class MediaStoreSource extends CollectionSource {
|
|||
// show known entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
|
||||
addEntries(oldEntries);
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load catalog metadata');
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
|
||||
await loadCatalogMetadata();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load address metadata');
|
||||
await loadAddresses();
|
||||
updateDerivedFilters();
|
||||
|
||||
// clean up obsolete entries
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
|
@ -110,11 +111,12 @@ class MediaStoreSource extends CollectionSource {
|
|||
updateDirectories();
|
||||
}
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} catalog entries');
|
||||
await catalogEntries();
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} locate entries');
|
||||
await locateEntries();
|
||||
stateNotifier.value = SourceState.ready;
|
||||
Set<AvesEntry>? analysisEntries;
|
||||
final analysisIds = analysisController?.contentIds;
|
||||
if (analysisIds != null) {
|
||||
analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.contentId)).toSet();
|
||||
}
|
||||
await analyze(analysisController, entries: analysisEntries);
|
||||
|
||||
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
|
||||
// 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`
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris) async {
|
||||
@override
|
||||
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}) async {
|
||||
if (!_initialized || !isMonitoring) return changedUris;
|
||||
|
||||
debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
|
||||
|
@ -180,18 +183,10 @@ class MediaStoreSource extends CollectionSource {
|
|||
addEntries(newEntries);
|
||||
await metadataDb.saveEntries(newEntries);
|
||||
cleanEmptyAlbums(existingDirectories);
|
||||
await catalogEntries();
|
||||
await locateEntries();
|
||||
stateNotifier.value = SourceState.ready;
|
||||
|
||||
await analyze(analysisController, entries: newEntries);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
19
lib/model/source/source_state.dart
Normal file
19
lib/model/source/source_state.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/tag.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/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -8,22 +9,25 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
mixin TagMixin on SourceBase {
|
||||
static const _commitCountThreshold = 300;
|
||||
static const commitCountThreshold = 400;
|
||||
static const _stopCheckCountThreshold = 100;
|
||||
|
||||
List<String> sortedTags = List.unmodifiable([]);
|
||||
|
||||
Future<void> loadCatalogMetadata() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final saved = await metadataDb.loadMetadataEntries();
|
||||
final idMap = entryById;
|
||||
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
|
||||
// debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} entries');
|
||||
onCatalogMetadataChanged();
|
||||
}
|
||||
|
||||
Future<void> catalogEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final todo = visibleEntries.where((entry) => !entry.isCatalogued).toList();
|
||||
static bool catalogEntriesTest(AvesEntry entry) => !entry.isCatalogued;
|
||||
|
||||
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;
|
||||
|
||||
stateNotifier.value = SourceState.cataloguing;
|
||||
|
@ -31,22 +35,26 @@ mixin TagMixin on SourceBase {
|
|||
final progressTotal = todo.length;
|
||||
setProgress(done: progressDone, total: progressTotal);
|
||||
|
||||
final newMetadata = <CatalogMetadata>[];
|
||||
await Future.forEach<AvesEntry>(todo, (entry) async {
|
||||
await entry.catalog(background: true);
|
||||
var stopCheckCount = 0;
|
||||
final newMetadata = <CatalogMetadata>{};
|
||||
for (final entry in todo) {
|
||||
await entry.catalog(background: true, persist: true, force: force);
|
||||
if (entry.isCatalogued) {
|
||||
newMetadata.add(entry.catalogMetadata!);
|
||||
if (newMetadata.length >= _commitCountThreshold) {
|
||||
await metadataDb.saveMetadata(Set.of(newMetadata));
|
||||
if (newMetadata.length >= commitCountThreshold) {
|
||||
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
newMetadata.clear();
|
||||
}
|
||||
if (++stopCheckCount >= _stopCheckCountThreshold) {
|
||||
stopCheckCount = 0;
|
||||
if (controller.isStopping) return;
|
||||
}
|
||||
}
|
||||
setProgress(done: ++progressDone, total: progressTotal);
|
||||
});
|
||||
await metadataDb.saveMetadata(Set.of(newMetadata));
|
||||
}
|
||||
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
|
||||
onCatalogMetadataChanged();
|
||||
// debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s');
|
||||
}
|
||||
|
||||
void onCatalogMetadataChanged() {
|
||||
|
|
|
@ -46,7 +46,7 @@ class MimeTypes {
|
|||
static const mov = 'video/quicktime';
|
||||
static const mp2t = 'video/mp2t'; // .m2ts
|
||||
static const mp4 = 'video/mp4';
|
||||
static const ogg = 'video/ogg';
|
||||
static const ogv = 'video/ogg';
|
||||
static const webm = 'video/webm';
|
||||
|
||||
static const json = 'application/json';
|
||||
|
@ -67,7 +67,7 @@ class MimeTypes {
|
|||
|
||||
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};
|
||||
|
||||
|
|
|
@ -13,25 +13,36 @@ class XMP {
|
|||
'crs': 'Camera Raw Settings',
|
||||
'dc': 'Dublin Core',
|
||||
'drone-dji': 'DJI Drone',
|
||||
'exif': 'Exif',
|
||||
'exifEX': 'Exif Ex',
|
||||
'GettyImagesGIFT': 'Getty Images',
|
||||
'GAudio': 'Google Audio',
|
||||
'GDepth': 'Google Depth',
|
||||
'GImage': 'Google Image',
|
||||
'GIMP': 'GIMP',
|
||||
'GCamera': 'Google Camera',
|
||||
'GCreations': 'Google Creations',
|
||||
'GFocus': 'Google Focus',
|
||||
'GPano': 'Google Panorama',
|
||||
'illustrator': 'Illustrator',
|
||||
'Iptc4xmpCore': 'IPTC Core',
|
||||
'Iptc4xmpExt': 'IPTC Extension',
|
||||
'lr': 'Lightroom',
|
||||
'MicrosoftPhoto': 'Microsoft Photo',
|
||||
'mwg-rs': 'Regions',
|
||||
'panorama': 'Panorama',
|
||||
'PanoStudioXMP': 'PanoramaStudio',
|
||||
'pdf': 'PDF',
|
||||
'pdfx': 'PDF/X',
|
||||
'PanoStudioXMP': 'PanoramaStudio',
|
||||
'photomechanic': 'Photo Mechanic',
|
||||
'photoshop': 'Photoshop',
|
||||
'plus': 'PLUS',
|
||||
'pmtm': 'Photomatix',
|
||||
'tiff': 'TIFF',
|
||||
'xmp': 'Basic',
|
||||
'xmpBJ': 'Basic Job Ticket',
|
||||
'xmpDM': 'Dynamic Media',
|
||||
'xmpMM': 'Media Management',
|
||||
'xmpRights': 'Rights Management',
|
||||
'xmpTPg': 'Paged-Text',
|
||||
};
|
||||
|
|
189
lib/services/analysis_service.dart
Normal file
189
lib/services/analysis_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -10,10 +10,35 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/services.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 Future<Set<Package>> getPackages() async {
|
||||
@override
|
||||
Future<Set<Package>> getPackages() async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getPackages');
|
||||
final packages = (result as List).cast<Map>().map((map) => Package.fromMap(map)).toSet();
|
||||
|
@ -29,7 +54,8 @@ class AndroidAppService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<Uint8List> getAppIcon(String packageName, double size) async {
|
||||
@override
|
||||
Future<Uint8List> getAppIcon(String packageName, double size) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getAppIcon', <String, dynamic>{
|
||||
'packageName': packageName,
|
||||
|
@ -42,7 +68,8 @@ class AndroidAppService {
|
|||
return Uint8List(0);
|
||||
}
|
||||
|
||||
static Future<bool> copyToClipboard(String uri, String? label) async {
|
||||
@override
|
||||
Future<bool> copyToClipboard(String uri, String? label) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('copyToClipboard', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -55,7 +82,8 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> edit(String uri, String mimeType) async {
|
||||
@override
|
||||
Future<bool> edit(String uri, String mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('edit', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -68,7 +96,8 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> open(String uri, String mimeType) async {
|
||||
@override
|
||||
Future<bool> open(String uri, String mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('open', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -81,7 +110,8 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> openMap(LatLng latLng) async {
|
||||
@override
|
||||
Future<bool> openMap(LatLng latLng) async {
|
||||
final latitude = roundToPrecision(latLng.latitude, decimals: 6);
|
||||
final longitude = roundToPrecision(latLng.longitude, decimals: 6);
|
||||
final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude';
|
||||
|
@ -97,7 +127,8 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> setAs(String uri, String mimeType) async {
|
||||
@override
|
||||
Future<bool> setAs(String uri, String mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('setAs', <String, dynamic>{
|
||||
'uri': uri,
|
||||
|
@ -110,7 +141,8 @@ class AndroidAppService {
|
|||
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
|
||||
// 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()));
|
||||
|
@ -125,7 +157,8 @@ class AndroidAppService {
|
|||
return false;
|
||||
}
|
||||
|
||||
static Future<bool> shareSingle(String uri, String mimeType) async {
|
||||
@override
|
||||
Future<bool> shareSingle(String uri, String mimeType) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('share', <String, dynamic>{
|
||||
'urisByMimeType': {
|
||||
|
@ -142,9 +175,10 @@ class AndroidAppService {
|
|||
// app shortcuts
|
||||
|
||||
// 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!);
|
||||
|
||||
try {
|
||||
|
@ -159,7 +193,8 @@ class AndroidAppService {
|
|||
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;
|
||||
if (entry != null) {
|
||||
final size = entry.isVideo ? 0.0 : 256.0;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/availability.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/media/embedded_data_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 MetadataDb metadataDb = getIt<MetadataDb>();
|
||||
|
||||
final AndroidAppService androidAppService = getIt<AndroidAppService>();
|
||||
final DeviceService deviceService = getIt<DeviceService>();
|
||||
final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
|
||||
final MediaFileService mediaFileService = getIt<MediaFileService>();
|
||||
|
@ -33,6 +35,7 @@ void initPlatformServices() {
|
|||
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
|
||||
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
|
||||
|
||||
getIt.registerLazySingleton<AndroidAppService>(() => PlatformAndroidAppService());
|
||||
getIt.registerLazySingleton<DeviceService>(() => PlatformDeviceService());
|
||||
getIt.registerLazySingleton<EmbeddedDataService>(() => PlatformEmbeddedDataService());
|
||||
getIt.registerLazySingleton<MediaFileService>(() => PlatformMediaFileService());
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
@ -9,19 +10,21 @@ class GeocodingService {
|
|||
static const platform = MethodChannel('deckers.thibault/aves/geocoding');
|
||||
|
||||
// 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 {
|
||||
final result = await platform.invokeMethod('getAddress', <String, dynamic>{
|
||||
'latitude': coordinates.latitude,
|
||||
'longitude': coordinates.longitude,
|
||||
'locale': locale,
|
||||
'locale': locale.toString(),
|
||||
// we only really need one address, but sometimes the native geocoder
|
||||
// returns nothing with `maxResults` of 1, but succeeds with `maxResults` of 2+
|
||||
'maxResults': 2,
|
||||
});
|
||||
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (e.code != 'getAddress-empty') {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
20
lib/services/media/enums.dart
Normal file
20
lib/services/media/enums.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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/service_policy.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/enums.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
@ -73,12 +74,19 @@ abstract class MediaFileService {
|
|||
Iterable<AvesEntry> entries, {
|
||||
required bool copy,
|
||||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
});
|
||||
|
||||
Stream<ExportOpEvent> export(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String mimeType,
|
||||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
});
|
||||
|
||||
Stream<MoveOpEvent> rename(
|
||||
Iterable<AvesEntry> entries, {
|
||||
required String newName,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> captureFrame(
|
||||
|
@ -87,9 +95,8 @@ abstract class MediaFileService {
|
|||
required Map<String, dynamic> exif,
|
||||
required Uint8List bytes,
|
||||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> rename(AvesEntry entry, String newName);
|
||||
}
|
||||
|
||||
class PlatformMediaFileService implements MediaFileService {
|
||||
|
@ -305,6 +312,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
Iterable<AvesEntry> entries, {
|
||||
required bool copy,
|
||||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
}) {
|
||||
try {
|
||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
|
@ -312,6 +320,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
'copy': copy,
|
||||
'destinationPath': destinationAlbum,
|
||||
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
|
||||
}).map((event) => MoveOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e, stack) {
|
||||
reportService.recordError(e, stack);
|
||||
|
@ -324,6 +333,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
Iterable<AvesEntry> entries, {
|
||||
required String mimeType,
|
||||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
}) {
|
||||
try {
|
||||
return _opStreamChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
|
@ -331,6 +341,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
'mimeType': mimeType,
|
||||
'destinationPath': destinationAlbum,
|
||||
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
|
||||
}).map((event) => ExportOpEvent.fromMap(event));
|
||||
} on PlatformException catch (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
|
||||
Future<Map<String, dynamic>> captureFrame(
|
||||
AvesEntry entry, {
|
||||
|
@ -345,6 +373,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
required Map<String, dynamic> exif,
|
||||
required Uint8List bytes,
|
||||
required String destinationAlbum,
|
||||
required NameConflictStrategy nameConflictStrategy,
|
||||
}) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('captureFrame', <String, dynamic>{
|
||||
|
@ -353,21 +382,7 @@ class PlatformMediaFileService implements MediaFileService {
|
|||
'exif': exif,
|
||||
'bytes': bytes,
|
||||
'destinationPath': destinationAlbum,
|
||||
});
|
||||
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,
|
||||
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
|
|
|
@ -40,7 +40,9 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -118,7 +120,9 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
}
|
||||
return MultiPageInfo.fromPageMaps(entry, pageMaps);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -23,8 +23,15 @@ abstract class StorageService {
|
|||
// returns number of deleted directories
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
Future<bool> requestVolumeAccess(String volumePath);
|
||||
// returns whether user granted access to a directory of his choosing
|
||||
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)
|
||||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes);
|
||||
|
@ -127,13 +134,37 @@ class PlatformStorageService implements StorageService {
|
|||
return 0;
|
||||
}
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
@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 {
|
||||
final completer = Completer<bool>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'requestVolumeAccess',
|
||||
'op': 'requestDirectoryAccess',
|
||||
'path': volumePath,
|
||||
}).listen(
|
||||
(data) => completer.complete(data as bool),
|
||||
|
@ -150,6 +181,30 @@ class PlatformStorageService implements StorageService {
|
|||
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
|
||||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
|
||||
try {
|
||||
|
|
|
@ -46,6 +46,7 @@ class AIcons {
|
|||
static const IconData flip = Icons.flip_outlined;
|
||||
static const IconData favourite = Icons.favorite_border;
|
||||
static const IconData favouriteActive = Icons.favorite;
|
||||
static const IconData geoBounds = Icons.public_outlined;
|
||||
static const IconData goUp = Icons.arrow_upward_outlined;
|
||||
static const IconData group = Icons.group_work_outlined;
|
||||
static const IconData hide = Icons.visibility_off_outlined;
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import 'package:aves/services/android_app_service.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:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
@ -16,7 +14,7 @@ class AndroidFileUtils {
|
|||
List<String> _potentialAppDirs = [];
|
||||
bool _initialized = false;
|
||||
|
||||
AChangeNotifier appNameChangeNotifier = AChangeNotifier();
|
||||
ValueNotifier<bool> areAppNamesReadyNotifier = ValueNotifier(false);
|
||||
|
||||
Iterable<Package> get _launcherPackages => _packages.where((package) => package.categoryLauncher);
|
||||
|
||||
|
@ -41,9 +39,9 @@ class AndroidFileUtils {
|
|||
|
||||
Future<void> initAppNames() async {
|
||||
if (_packages.isEmpty) {
|
||||
_packages = await AndroidAppService.getPackages();
|
||||
_packages = await androidAppService.getPackages();
|
||||
_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
|
||||
static VolumeRelativeDirectory? fromPath(String dirPath) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
|
|
|
@ -1,27 +1,79 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
LatLng getLatLngCenter(List<LatLng> points) {
|
||||
double x = 0;
|
||||
double y = 0;
|
||||
double z = 0;
|
||||
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]),
|
||||
];
|
||||
}
|
||||
|
||||
points.forEach((point) {
|
||||
final lat = point.latitudeInRad;
|
||||
final lng = point.longitudeInRad;
|
||||
x += cos(lat) * cos(lng);
|
||||
y += cos(lat) * sin(lng);
|
||||
z += sin(lat);
|
||||
});
|
||||
final deg = _split(degDecimal)[0];
|
||||
final minDecimal = (degDecimal.abs() - deg) * 60;
|
||||
final min = _split(minDecimal)[0];
|
||||
final sec = (minDecimal - min) * 60;
|
||||
|
||||
final pointCount = points.length;
|
||||
x /= pointCount;
|
||||
y /= pointCount;
|
||||
z /= pointCount;
|
||||
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');
|
||||
}
|
||||
|
||||
final lng = atan2(y, x);
|
||||
final hyp = sqrt(x * x + y * y);
|
||||
final lat = atan2(z, hyp);
|
||||
return LatLng(radianToDeg(lat), radianToDeg(lng));
|
||||
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 y = 0;
|
||||
double z = 0;
|
||||
|
||||
points.forEach((point) {
|
||||
final lat = point.latitudeInRad;
|
||||
final lng = point.longitudeInRad;
|
||||
x += cos(lat) * cos(lng);
|
||||
y += cos(lat) * sin(lng);
|
||||
z += sin(lat);
|
||||
});
|
||||
|
||||
final pointCount = points.length;
|
||||
x /= pointCount;
|
||||
y /= pointCount;
|
||||
z /= pointCount;
|
||||
|
||||
final lng = atan2(y, x);
|
||||
final hyp = sqrt(x * x + y * y);
|
||||
final lat = atan2(z, hyp);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/welcome_page.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:fijkplayer/fijkplayer.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -46,6 +47,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/media_store_change');
|
||||
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 GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
|
@ -58,6 +60,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
_appSetup = _setup();
|
||||
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
|
||||
_analysisCompletionChannel.receiveBroadcastStream().listen((event) => _onAnalysisCompletion());
|
||||
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
|
||||
}
|
||||
|
||||
|
@ -144,9 +147,11 @@ class _AvesAppState extends State<AvesApp> {
|
|||
|
||||
Future<void> _setup() async {
|
||||
await settings.init(
|
||||
monitorPlatformSettings: true,
|
||||
isRotationLocked: await windowService.isRotationLocked(),
|
||||
areAnimationsRemoved: await AccessibilityService.areAnimationsRemoved(),
|
||||
);
|
||||
FijkLog.setLevel(FijkLogLevel.Warn);
|
||||
|
||||
// keep screen on
|
||||
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) {
|
||||
if (uri != null) changedUris.add(uri);
|
||||
if (changedUris.isNotEmpty) {
|
||||
|
|
|
@ -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_source.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/widgets/collection/entry_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/collection/filter_bar.dart';
|
||||
|
@ -61,7 +61,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
vsync: this,
|
||||
);
|
||||
_isSelectingNotifier.addListener(_onActivityChange);
|
||||
_canAddShortcutsLoader = AndroidAppService.canPinToHomeScreen();
|
||||
_canAddShortcutsLoader = androidAppService.canPinToHomeScreen();
|
||||
_registerWidget(widget);
|
||||
WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged());
|
||||
}
|
||||
|
@ -200,17 +200,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
final otherViewEnabled = (!isSelecting && hasItems) || (isSelecting && hasSelection);
|
||||
|
||||
return [
|
||||
_toMenuItem(
|
||||
EntrySetAction.sort,
|
||||
// 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'),
|
||||
),
|
||||
_toMenuItem(EntrySetAction.sort),
|
||||
if (groupable) _toMenuItem(EntrySetAction.group),
|
||||
if (appMode == AppMode.main) ...[
|
||||
if (!isSelecting)
|
||||
_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(
|
||||
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,
|
||||
enabled: enabled,
|
||||
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
|
||||
// because some filter labels need localization
|
||||
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>>(
|
||||
context: context,
|
||||
|
@ -371,7 +363,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
final name = result.item2;
|
||||
if (name.isEmpty) return;
|
||||
|
||||
unawaited(AndroidAppService.pinToHomeScreen(name, coverEntry, filters));
|
||||
unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters));
|
||||
}
|
||||
|
||||
void _goToSearch() {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:aves/model/actions/entry_set_actions.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/highlight.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_source.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/enums.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/common/action_mixins/feedback.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/extensions/build_context.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/map/map_page.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
|
@ -63,7 +65,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
void _share(BuildContext context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
AndroidAppService.shareEntries(selectedItems).then((success) {
|
||||
androidAppService.shareEntries(selectedItems).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
});
|
||||
}
|
||||
|
@ -73,29 +75,18 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
source.rescan(selectedItems);
|
||||
final controller = AnalysisController(canStartService: true, force: true);
|
||||
source.analyze(controller, entries: selectedItems);
|
||||
|
||||
selection.browse();
|
||||
}
|
||||
|
||||
Future<void> _moveSelection(BuildContext context, {required MoveType moveType}) async {
|
||||
final l10n = context.l10n;
|
||||
final source = context.read<CollectionSource>();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
final selectedItems = _getExpandedSelectedItems(selection);
|
||||
|
||||
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(
|
||||
context,
|
||||
|
@ -107,7 +98,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
if (destinationAlbum == null || destinationAlbum.isEmpty) 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;
|
||||
|
||||
|
@ -119,13 +110,44 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final todoCount = todoEntries.length;
|
||||
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();
|
||||
showOpReport<MoveOpEvent>(
|
||||
context: context,
|
||||
opStream: mediaFileService.move(todoEntries, copy: copy, destinationAlbum: destinationAlbum),
|
||||
opStream: mediaFileService.move(
|
||||
todoEntries,
|
||||
copy: copy,
|
||||
destinationAlbum: destinationAlbum,
|
||||
nameConflictStrategy: nameConflictStrategy,
|
||||
),
|
||||
itemCount: todoCount,
|
||||
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(
|
||||
todoEntries: todoEntries,
|
||||
copy: copy,
|
||||
|
@ -140,50 +162,51 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||
}
|
||||
|
||||
final l10n = context.l10n;
|
||||
final movedCount = movedOps.length;
|
||||
if (movedCount < todoCount) {
|
||||
final count = todoCount - movedCount;
|
||||
final successCount = successOps.length;
|
||||
if (successCount < todoCount) {
|
||||
final count = todoCount - successCount;
|
||||
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
|
||||
} else {
|
||||
final count = movedCount;
|
||||
final count = movedOps.length;
|
||||
showFeedback(
|
||||
context,
|
||||
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
|
||||
SnackBarAction(
|
||||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
final collection = context.read<CollectionLens>();
|
||||
var targetCollection = collection;
|
||||
if (collection.filters.any((f) => f is AlbumFilter)) {
|
||||
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
|
||||
// we could simply add the filter to the current collection
|
||||
// but navigating makes the change less jarring
|
||||
targetCollection = CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
)..addFilter(filter);
|
||||
unawaited(Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(
|
||||
collection: targetCollection,
|
||||
),
|
||||
),
|
||||
));
|
||||
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||
await Future.delayed(delayDuration);
|
||||
}
|
||||
await Future.delayed(Durations.highlightScrollInitDelay);
|
||||
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
|
||||
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
|
||||
if (targetEntry != null) {
|
||||
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
|
||||
}
|
||||
},
|
||||
),
|
||||
count > 0
|
||||
? SnackBarAction(
|
||||
label: l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
final collection = context.read<CollectionLens>();
|
||||
var targetCollection = collection;
|
||||
if (collection.filters.any((f) => f is AlbumFilter)) {
|
||||
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
|
||||
// we could simply add the filter to the current collection
|
||||
// but navigating makes the change less jarring
|
||||
targetCollection = CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
)..addFilter(filter);
|
||||
unawaited(Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: CollectionPage.routeName),
|
||||
builder: (context) => CollectionPage(
|
||||
collection: targetCollection,
|
||||
),
|
||||
),
|
||||
));
|
||||
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
|
||||
await Future.delayed(delayDuration);
|
||||
}
|
||||
await Future.delayed(Durations.highlightScrollInitDelay);
|
||||
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
|
||||
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
|
||||
if (targetEntry != null) {
|
||||
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
|
||||
}
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
@ -218,7 +241,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
if (!await checkStoragePermissionForAlbums(context, selectionDirs)) return;
|
||||
if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
|
||||
|
||||
source.pauseMonitoring();
|
||||
showOpReport<ImageOpEvent>(
|
||||
|
@ -253,6 +276,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: MapPage.routeName),
|
||||
builder: (context) => MapPage(
|
||||
// need collection with fresh ID to prevent hero from scroller on Map page to Collection page
|
||||
collection: CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
|
|
|
@ -73,10 +73,7 @@ class InteractiveThumbnail extends StatelessWidget {
|
|||
TransparentMaterialPageRoute(
|
||||
settings: const RouteSettings(name: EntryViewerPage.routeName),
|
||||
pageBuilder: (context, a, sa) {
|
||||
final viewerCollection = CollectionLens(
|
||||
source: collection.source,
|
||||
filters: collection.filters,
|
||||
id: collection.id,
|
||||
final viewerCollection = collection.copyWith(
|
||||
listenToSource: false,
|
||||
);
|
||||
assert(viewerCollection.sortedEntries.map((e) => e.contentId).contains(entry.contentId));
|
||||
|
|
|
@ -8,21 +8,41 @@ import 'package:flutter/material.dart';
|
|||
|
||||
mixin PermissionAwareMixin {
|
||||
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();
|
||||
while (true) {
|
||||
final dirs = await storageService.getInaccessibleDirectories(albumPaths);
|
||||
if (dirs.isEmpty) return true;
|
||||
|
||||
final restrictedInaccessibleDir = dirs.firstWhereOrNull(restrictedDirs.contains);
|
||||
if (restrictedInaccessibleDir != null) {
|
||||
await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir);
|
||||
return false;
|
||||
final restrictedInaccessibleDirs = dirs.where(restrictedDirs.contains).toSet();
|
||||
if (restrictedInaccessibleDirs.isNotEmpty) {
|
||||
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;
|
||||
}
|
||||
// clear restricted directories
|
||||
dirs.removeAll(restrictedInaccessibleDirs);
|
||||
}
|
||||
|
||||
if (dirs.isEmpty) return true;
|
||||
|
||||
final dir = dirs.first;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
|
@ -49,7 +69,7 @@ mixin PermissionAwareMixin {
|
|||
// abort if the user cancels in Flutter
|
||||
if (confirmed == null || !confirmed) return false;
|
||||
|
||||
final granted = await storageService.requestVolumeAccess(dir.volumePath);
|
||||
final granted = await storageService.requestDirectoryAccess(dir.volumePath);
|
||||
if (!granted) {
|
||||
// abort if the user denies access from the native dialog
|
||||
return false;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:aves/model/source/collection_source.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/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -56,43 +57,29 @@ class SourceStateSubtitle extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? subtitle;
|
||||
switch (source.stateNotifier.value) {
|
||||
case SourceState.loading:
|
||||
subtitle = context.l10n.sourceStateLoading;
|
||||
break;
|
||||
case SourceState.cataloguing:
|
||||
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,
|
||||
children: [
|
||||
Text(subtitle, style: subtitleStyle),
|
||||
StreamBuilder<ProgressEvent>(
|
||||
stream: source.progressStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError || !snapshot.hasData) return const SizedBox.shrink();
|
||||
final progress = snapshot.data!;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle!.copyWith(color: Colors.white30),
|
||||
),
|
||||
);
|
||||
},
|
||||
final sourceState = source.stateNotifier.value;
|
||||
final subtitle = sourceState.getName(context.l10n);
|
||||
if (subtitle == null) return const SizedBox();
|
||||
|
||||
final subtitleStyle = Theme.of(context).textTheme.caption!;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(subtitle, style: subtitleStyle),
|
||||
ValueListenableBuilder<ProgressEvent>(
|
||||
valueListenable: source.progressNotifier,
|
||||
builder: (context, progress, snapshot) {
|
||||
if (progress.total == 0 || sourceState == SourceState.locatingCountries) return const SizedBox();
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 8),
|
||||
child: Text(
|
||||
'${progress.done}/${progress.total}',
|
||||
style: subtitleStyle.copyWith(color: Colors.white30),
|
||||
),
|
||||
],
|
||||
);
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/widgets/common/extensions/media_query.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -13,17 +11,48 @@ class BottomGestureAreaProtector extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => mq.systemGestureInsets.bottom,
|
||||
builder: (context, systemGestureBottom, child) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: systemGestureBottom,
|
||||
child: const AbsorbPointer(),
|
||||
);
|
||||
},
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: context.select<MediaQueryData, double>((mq) => mq.systemGestureInsets.bottom),
|
||||
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) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Selector<MediaQueryData, double>(
|
||||
selector: (context, mq) => max(mq.effectiveBottomPadding, mq.systemGestureInsets.bottom),
|
||||
selector: (context, mq) => mq.effectiveBottomPadding,
|
||||
builder: (context, mqPaddingBottom, child) {
|
||||
return SizedBox(height: mqPaddingBottom);
|
||||
},
|
||||
|
|
|
@ -13,6 +13,7 @@ class TransitionImage extends StatefulWidget {
|
|||
final double? width, height;
|
||||
final ValueListenable<double> animation;
|
||||
final bool gaplessPlayback = false;
|
||||
final Color? background;
|
||||
|
||||
const TransitionImage({
|
||||
Key? key,
|
||||
|
@ -20,6 +21,7 @@ class TransitionImage extends StatefulWidget {
|
|||
required this.animation,
|
||||
this.width,
|
||||
this.height,
|
||||
this.background,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -136,10 +138,10 @@ class _TransitionImageState extends State<TransitionImage> {
|
|||
valueListenable: widget.animation,
|
||||
builder: (context, t, child) => CustomPaint(
|
||||
painter: _TransitionImagePainter(
|
||||
// AssetImage(name).resolve(configuration)
|
||||
image: _imageInfo?.image,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
t: t,
|
||||
background: widget.background,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -150,11 +152,13 @@ class _TransitionImagePainter extends CustomPainter {
|
|||
final ui.Image? image;
|
||||
final double scale;
|
||||
final double t;
|
||||
final Color? background;
|
||||
|
||||
const _TransitionImagePainter({
|
||||
required this.image,
|
||||
required this.scale,
|
||||
required this.t,
|
||||
this.background,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -185,6 +189,9 @@ class _TransitionImagePainter extends CustomPainter {
|
|||
sourceSize,
|
||||
Offset.zero & inputSize,
|
||||
);
|
||||
if (background != null) {
|
||||
canvas.drawRect(destinationRect, Paint()..color = background!);
|
||||
}
|
||||
canvas.drawImageRect(image!, sourceRect, destinationRect, paint);
|
||||
}
|
||||
|
||||
|
|
|
@ -37,12 +37,11 @@ class AvesFilterDecoration {
|
|||
|
||||
class AvesFilterChip extends StatefulWidget {
|
||||
final CollectionFilter filter;
|
||||
final bool removable;
|
||||
final bool showGenericIcon;
|
||||
final bool removable, showGenericIcon, useFilterColor;
|
||||
final AvesFilterDecoration? decoration;
|
||||
final String? banner;
|
||||
final Widget? details;
|
||||
final double padding;
|
||||
final double padding, maxWidth;
|
||||
final HeroType heroType;
|
||||
final FilterCallback? onTap;
|
||||
final OffsetFilterCallback? onLongPress;
|
||||
|
@ -52,7 +51,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
static const double outlineWidth = 2;
|
||||
static const double minChipHeight = kMinInteractiveDimension;
|
||||
static const double minChipWidth = 80;
|
||||
static const double maxChipWidth = 160;
|
||||
static const double defaultMaxChipWidth = 160;
|
||||
static const double iconSize = 18;
|
||||
static const double fontSize = 14;
|
||||
static const double decoratedContentVerticalPadding = 5;
|
||||
|
@ -62,10 +61,12 @@ class AvesFilterChip extends StatefulWidget {
|
|||
required this.filter,
|
||||
this.removable = false,
|
||||
this.showGenericIcon = true,
|
||||
this.useFilterColor = true,
|
||||
this.decoration,
|
||||
this.banner,
|
||||
this.details,
|
||||
this.padding = 6.0,
|
||||
this.maxWidth = defaultMaxChipWidth,
|
||||
this.heroType = HeroType.onTap,
|
||||
this.onTap,
|
||||
this.onLongPress = showDefaultLongPressMenu,
|
||||
|
@ -181,7 +182,6 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
),
|
||||
softWrap: false,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
|
@ -216,7 +216,7 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
);
|
||||
} else {
|
||||
content = Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: padding * 2, vertical: 2),
|
||||
padding: EdgeInsets.symmetric(horizontal: padding * 2),
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
@ -224,9 +224,9 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
final borderRadius = decoration?.chipBorderRadius ?? const BorderRadius.all(Radius.circular(AvesFilterChip.defaultRadius));
|
||||
final banner = widget.banner;
|
||||
Widget chip = Container(
|
||||
constraints: const BoxConstraints(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: AvesFilterChip.minChipWidth,
|
||||
maxWidth: AvesFilterChip.maxChipWidth,
|
||||
maxWidth: widget.maxWidth,
|
||||
minHeight: AvesFilterChip.minChipHeight,
|
||||
),
|
||||
child: Stack(
|
||||
|
@ -263,16 +263,13 @@ class _AvesFilterChipState extends State<AvesFilterChip> {
|
|||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(BorderSide(
|
||||
color: _outlineColor,
|
||||
color: widget.useFilterColor ? _outlineColor : AvesFilterChip.defaultOutlineColor,
|
||||
width: AvesFilterChip.outlineWidth,
|
||||
)),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
position: DecorationPosition.foreground,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: decoration != null ? 0 : 8),
|
||||
child: content,
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import 'package:aves/model/filters/coordinate.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/map_style.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.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/fx/blurred.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/theme.dart';
|
||||
import 'package:aves/widgets/common/map/zoomed_bounds.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:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
@ -58,119 +62,126 @@ class MapButtonPanel extends StatelessWidget {
|
|||
break;
|
||||
}
|
||||
|
||||
final showCoordinateFilter = context.select<MapThemeData, bool>((v) => v.showCoordinateFilter);
|
||||
final visualDensity = context.select<MapThemeData, VisualDensity?>((v) => v.visualDensity);
|
||||
final double padding = visualDensity == VisualDensity.compact ? 4 : 8;
|
||||
|
||||
return Positioned.fill(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (navigationButton != null) ...[
|
||||
navigationButton,
|
||||
SizedBox(height: padding),
|
||||
],
|
||||
ValueListenableBuilder<ZoomedBounds>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, bounds, child) {
|
||||
final degrees = bounds.rotation;
|
||||
final opacity = degrees == 0 ? .0 : 1.0;
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.viewerOverlayAnimation);
|
||||
return IgnorePointer(
|
||||
ignoring: opacity == 0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: animationDuration,
|
||||
child: MapOverlayButton(
|
||||
icon: Transform(
|
||||
origin: iconSize.center(Offset.zero),
|
||||
transform: Matrix4.rotationZ(degToRadian(degrees)),
|
||||
child: CustomPaint(
|
||||
painter: CompassPainter(
|
||||
color: iconTheme.color!,
|
||||
child: TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: padding,
|
||||
right: padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: padding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (navigationButton != null) ...[
|
||||
navigationButton,
|
||||
SizedBox(height: padding),
|
||||
],
|
||||
ValueListenableBuilder<ZoomedBounds>(
|
||||
valueListenable: boundsNotifier,
|
||||
builder: (context, bounds, child) {
|
||||
final degrees = bounds.rotation;
|
||||
final opacity = degrees == 0 ? .0 : 1.0;
|
||||
final animationDuration = context.select<DurationsData, Duration>((v) => v.viewerOverlayAnimation);
|
||||
return IgnorePointer(
|
||||
ignoring: opacity == 0,
|
||||
child: AnimatedOpacity(
|
||||
opacity: opacity,
|
||||
duration: animationDuration,
|
||||
child: MapOverlayButton(
|
||||
icon: Transform(
|
||||
origin: iconSize.center(Offset.zero),
|
||||
transform: Matrix4.rotationZ(degToRadian(degrees)),
|
||||
child: CustomPaint(
|
||||
painter: CompassPainter(
|
||||
color: iconTheme.color!,
|
||||
),
|
||||
size: iconSize,
|
||||
),
|
||||
size: iconSize,
|
||||
),
|
||||
onPressed: () => resetRotation?.call(),
|
||||
tooltip: context.l10n.mapPointNorthUpTooltip,
|
||||
),
|
||||
onPressed: () => resetRotation?.call(),
|
||||
tooltip: context.l10n.mapPointNorthUpTooltip,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.layers),
|
||||
onPressed: () async {
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
|
||||
final preferredStyle = settings.infoMapStyle;
|
||||
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
initialValue: initialStyle,
|
||||
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.mapStyleTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.mapStyleTooltip,
|
||||
),
|
||||
],
|
||||
showCoordinateFilter
|
||||
? Expanded(
|
||||
child: _OverlayCoordinateFilterChip(
|
||||
boundsNotifier: boundsNotifier,
|
||||
padding: padding,
|
||||
),
|
||||
)
|
||||
: const Spacer(),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: padding),
|
||||
child: MapOverlayButton(
|
||||
icon: const Icon(AIcons.layers),
|
||||
onPressed: () async {
|
||||
final hasPlayServices = await availability.hasPlayServices;
|
||||
final availableStyles = EntryMapStyle.values.where((style) => !style.isGoogleMaps || hasPlayServices);
|
||||
final preferredStyle = settings.infoMapStyle;
|
||||
final initialStyle = availableStyles.contains(preferredStyle) ? preferredStyle : availableStyles.first;
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesSelectionDialog<EntryMapStyle>(
|
||||
initialValue: initialStyle,
|
||||
options: Map.fromEntries(availableStyles.map((v) => MapEntry(v, v.getName(context)))),
|
||||
title: context.l10n.mapStyleTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (style != null && style != settings.infoMapStyle) {
|
||||
settings.infoMapStyle = style;
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.mapStyleTooltip,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomIn),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
|
||||
tooltip: context.l10n.mapZoomInTooltip,
|
||||
),
|
||||
SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomOut),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
|
||||
tooltip: context.l10n.mapZoomOutTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: padding,
|
||||
bottom: padding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomIn),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(1) : null,
|
||||
tooltip: context.l10n.mapZoomInTooltip,
|
||||
),
|
||||
SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: const Icon(AIcons.zoomOut),
|
||||
onPressed: zoomBy != null ? () => zoomBy?.call(-1) : null,
|
||||
tooltip: context.l10n.mapZoomOutTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/model/settings/map_style.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/widgets/common/map/attribution.dart';
|
||||
|
@ -27,6 +28,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class GeoMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final Listenable? collectionListenable;
|
||||
final List<AvesEntry> entries;
|
||||
final AvesEntry? initialEntry;
|
||||
final ValueNotifier<bool> isAnimatingNotifier;
|
||||
|
@ -42,6 +44,7 @@ class GeoMap extends StatefulWidget {
|
|||
const GeoMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
this.collectionListenable,
|
||||
required this.entries,
|
||||
this.initialEntry,
|
||||
required this.isAnimatingNotifier,
|
||||
|
@ -57,27 +60,57 @@ class GeoMap extends StatefulWidget {
|
|||
}
|
||||
|
||||
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
|
||||
// it is especially severe the first time, but still significant afterwards
|
||||
// so we prevent loading it while scrolling or animating
|
||||
bool _googleMapsLoaded = false;
|
||||
late final ValueNotifier<ZoomedBounds> _boundsNotifier;
|
||||
late final Fluster<GeoEntry> _defaultMarkerCluster;
|
||||
Fluster<GeoEntry>? _defaultMarkerCluster;
|
||||
Fluster<GeoEntry>? _slowMarkerCluster;
|
||||
final AChangeNotifier _clusterChangeNotifier = AChangeNotifier();
|
||||
|
||||
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
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initialEntry = widget.initialEntry;
|
||||
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)]},
|
||||
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
|
||||
|
@ -92,11 +125,11 @@ class _GeoMapState extends State<GeoMap> {
|
|||
return {geoEntry.entry!};
|
||||
}
|
||||
|
||||
var points = _defaultMarkerCluster.points(clusterId);
|
||||
var points = _defaultMarkerCluster?.points(clusterId) ?? [];
|
||||
if (points.length != geoEntry.pointsSize) {
|
||||
// `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`)
|
||||
_slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length));
|
||||
_slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(entries.length));
|
||||
points = _slowMarkerCluster!.points(clusterId);
|
||||
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
|
||||
? EntryGoogleMap(
|
||||
controller: widget.controller,
|
||||
clusterListenable: _clusterChangeNotifier,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
minZoom: 0,
|
||||
maxZoom: 20,
|
||||
|
@ -151,6 +185,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
)
|
||||
: EntryLeafletMap(
|
||||
controller: widget.controller,
|
||||
clusterListenable: _clusterChangeNotifier,
|
||||
boundsNotifier: _boundsNotifier,
|
||||
minZoom: 2,
|
||||
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}) {
|
||||
final markers = entries.map((entry) {
|
||||
final latLng = entry.latLng!;
|
||||
|
@ -259,7 +300,7 @@ class _GeoMapState extends State<GeoMap> {
|
|||
|
||||
Map<MarkerKey, GeoEntry> _buildMarkerClusters() {
|
||||
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) {
|
||||
if (v.isCluster!) {
|
||||
final uri = v.childMarkerId;
|
||||
|
|
|
@ -21,6 +21,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class EntryGoogleMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final Listenable clusterListenable;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final double? minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
|
@ -35,6 +36,7 @@ class EntryGoogleMap extends StatefulWidget {
|
|||
const EntryGoogleMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.clusterListenable,
|
||||
required this.boundsNotifier,
|
||||
this.minZoom,
|
||||
this.maxZoom,
|
||||
|
@ -93,9 +95,11 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
if (avesMapController != null) {
|
||||
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(_toGoogleLatLng(event.latLng))));
|
||||
}
|
||||
widget.clusterListenable.addListener(_updateMarkers);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryGoogleMap widget) {
|
||||
widget.clusterListenable.removeListener(_updateMarkers);
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
|
@ -109,7 +113,7 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
case AppLifecycleState.detached:
|
||||
break;
|
||||
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
|
||||
_googleMapController?.setMapStyle(null);
|
||||
break;
|
||||
|
@ -167,52 +171,54 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
|
||||
final interactive = context.select<MapThemeData, bool>((v) => v.interactive);
|
||||
return ValueListenableBuilder<AvesEntry?>(
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) {
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: 0);
|
||||
setState(() {});
|
||||
},
|
||||
// compass disabled to use provider agnostic controls
|
||||
compassEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
mapType: _toMapType(widget.style),
|
||||
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
|
||||
rotateGesturesEnabled: true,
|
||||
scrollGesturesEnabled: interactive,
|
||||
// zoom controls disabled to use provider agnostic controls
|
||||
zoomControlsEnabled: false,
|
||||
zoomGesturesEnabled: interactive,
|
||||
// lite mode disabled because it lacks camera animation
|
||||
liteModeEnabled: false,
|
||||
// tilt disabled to match leaflet
|
||||
tiltGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: {
|
||||
...markers,
|
||||
if (dotEntry != null && _dotMarkerBitmap != null)
|
||||
Marker(
|
||||
markerId: const MarkerId('dot'),
|
||||
anchor: const Offset(.5, .5),
|
||||
consumeTapEvents: true,
|
||||
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
|
||||
position: _toGoogleLatLng(dotEntry.latLng!),
|
||||
zIndex: 1,
|
||||
)
|
||||
},
|
||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||
onCameraIdle: _onIdle,
|
||||
onTap: (position) => widget.onMapTap?.call(),
|
||||
);
|
||||
});
|
||||
valueListenable: widget.dotEntryNotifier ?? ValueNotifier(null),
|
||||
builder: (context, dotEntry, child) {
|
||||
return GoogleMap(
|
||||
initialCameraPosition: CameraPosition(
|
||||
bearing: -bounds.rotation,
|
||||
target: _toGoogleLatLng(bounds.center),
|
||||
zoom: bounds.zoom,
|
||||
),
|
||||
onMapCreated: (controller) async {
|
||||
_googleMapController = controller;
|
||||
final zoom = await controller.getZoomLevel();
|
||||
await _updateVisibleRegion(zoom: zoom, rotation: bounds.rotation);
|
||||
setState(() {});
|
||||
},
|
||||
// compass disabled to use provider agnostic controls
|
||||
compassEnabled: false,
|
||||
mapToolbarEnabled: false,
|
||||
mapType: _toMapType(widget.style),
|
||||
minMaxZoomPreference: MinMaxZoomPreference(widget.minZoom, widget.maxZoom),
|
||||
rotateGesturesEnabled: true,
|
||||
scrollGesturesEnabled: interactive,
|
||||
// zoom controls disabled to use provider agnostic controls
|
||||
zoomControlsEnabled: false,
|
||||
zoomGesturesEnabled: interactive,
|
||||
// lite mode disabled because it lacks camera animation
|
||||
liteModeEnabled: false,
|
||||
// tilt disabled to match leaflet
|
||||
tiltGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: {
|
||||
...markers,
|
||||
if (dotEntry != null && _dotMarkerBitmap != null)
|
||||
Marker(
|
||||
markerId: const MarkerId('dot'),
|
||||
anchor: const Offset(.5, .5),
|
||||
consumeTapEvents: true,
|
||||
icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!),
|
||||
position: _toGoogleLatLng(dotEntry.latLng!),
|
||||
zIndex: 1,
|
||||
)
|
||||
},
|
||||
onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing),
|
||||
onCameraIdle: _onIdle,
|
||||
onTap: (position) => widget.onMapTap?.call(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -220,6 +226,10 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
|
|||
void _onIdle() {
|
||||
if (!mounted) return;
|
||||
widget.controller?.notifyIdle(bounds);
|
||||
_updateMarkers();
|
||||
}
|
||||
|
||||
void _updateMarkers() {
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/rendering.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 {
|
||||
final List<Widget> markers;
|
||||
final bool Function(T markerKey) isReadyToRender;
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/settings/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/debouncer.dart';
|
||||
import 'package:aves/widgets/common/map/buttons.dart';
|
||||
|
@ -22,6 +23,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class EntryLeafletMap extends StatefulWidget {
|
||||
final AvesMapController? controller;
|
||||
final Listenable clusterListenable;
|
||||
final ValueNotifier<ZoomedBounds> boundsNotifier;
|
||||
final double minZoom, maxZoom;
|
||||
final EntryMapStyle style;
|
||||
|
@ -37,6 +39,7 @@ class EntryLeafletMap extends StatefulWidget {
|
|||
const EntryLeafletMap({
|
||||
Key? key,
|
||||
this.controller,
|
||||
required this.clusterListenable,
|
||||
required this.boundsNotifier,
|
||||
this.minZoom = 0,
|
||||
this.maxZoom = 22,
|
||||
|
@ -66,7 +69,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
|
||||
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);
|
||||
|
||||
@override
|
||||
|
@ -95,11 +98,13 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
_subscriptions.add(avesMapController.moveCommands.listen((event) => _moveTo(event.latLng)));
|
||||
}
|
||||
_subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion()));
|
||||
boundsNotifier.addListener(_onBoundsChange);
|
||||
widget.clusterListenable.addListener(_updateMarkers);
|
||||
widget.boundsNotifier.addListener(_onBoundsChange);
|
||||
}
|
||||
|
||||
void _unregisterWidget(EntryLeafletMap widget) {
|
||||
boundsNotifier.removeListener(_onBoundsChange);
|
||||
widget.clusterListenable.removeListener(_updateMarkers);
|
||||
widget.boundsNotifier.removeListener(_onBoundsChange);
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
|
@ -151,6 +156,7 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
options: MapOptions(
|
||||
center: bounds.center,
|
||||
zoom: bounds.zoom,
|
||||
rotation: bounds.rotation,
|
||||
minZoom: widget.minZoom,
|
||||
maxZoom: widget.maxZoom,
|
||||
// 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,
|
||||
nonRotatedChildren: [
|
||||
ScaleLayerWidget(
|
||||
options: ScaleLayerOptions(),
|
||||
options: ScaleLayerOptions(
|
||||
unitSystem: settings.unitSystem,
|
||||
),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
|
@ -212,6 +220,10 @@ class _EntryLeafletMapState extends State<EntryLeafletMap> with TickerProviderSt
|
|||
void _onIdle() {
|
||||
if (!mounted) return;
|
||||
widget.controller?.notifyIdle(bounds);
|
||||
_updateMarkers();
|
||||
}
|
||||
|
||||
void _updateMarkers() {
|
||||
setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder());
|
||||
}
|
||||
|
||||
|
|
|
@ -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/map/leaflet/scalebar_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
@ -7,10 +6,12 @@ import 'package:flutter_map/flutter_map.dart';
|
|||
import 'package:flutter_map/plugin_api.dart';
|
||||
|
||||
class ScaleLayerOptions extends LayerOptions {
|
||||
final UnitSystem unitSystem;
|
||||
final Widget Function(double width, String distance) builder;
|
||||
|
||||
ScaleLayerOptions({
|
||||
Key? key,
|
||||
this.unitSystem = UnitSystem.metric,
|
||||
this.builder = defaultBuilder,
|
||||
rebuild,
|
||||
}) : super(key: key, rebuild: rebuild);
|
||||
|
@ -41,7 +42,7 @@ class ScaleLayer extends StatelessWidget {
|
|||
|
||||
// ignore: prefer_void_to_null
|
||||
final Stream<Null> stream;
|
||||
final scale = [
|
||||
static const List<double> scaleMeters = [
|
||||
25000000,
|
||||
15000000,
|
||||
8000000,
|
||||
|
@ -67,6 +68,10 @@ class ScaleLayer extends StatelessWidget {
|
|||
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);
|
||||
|
||||
@override
|
||||
|
@ -83,11 +88,33 @@ class ScaleLayer extends StatelessWidget {
|
|||
: latitude > 60
|
||||
? 3
|
||||
: 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 targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance);
|
||||
final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distanceMeters);
|
||||
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);
|
||||
|
||||
return scaleLayerOpts.builder(width, displayDistance);
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
|||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
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 mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0;
|
||||
var mFlattening = 1.0 / 298.257223563;
|
||||
|
@ -18,7 +18,7 @@ class ScaleBarUtils {
|
|||
var alpha1 = degToRadian(startBearing);
|
||||
var cosAlpha1 = cos(alpha1);
|
||||
var sinAlpha1 = sin(alpha1);
|
||||
var s = distance;
|
||||
var s = distanceMeters;
|
||||
var tanU1 = (1.0 - f) * tan(phi1);
|
||||
var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1);
|
||||
var sinU1 = tanU1 * cosU1;
|
||||
|
|
|
@ -39,7 +39,7 @@ class ImageMarker extends StatelessWidget {
|
|||
)
|
||||
: const SizedBox();
|
||||
|
||||
// need to be sized for the Google Maps marker generator
|
||||
// need to be sized for the Google map marker generator
|
||||
child = SizedBox(
|
||||
width: extent,
|
||||
height: extent,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue