Merge branch 'develop'

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

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

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

View file

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

View file

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

View file

@ -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

View file

@ -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
...

View file

@ -35,9 +35,28 @@ Aves requires a few permissions to do its job:
- **have network access**: necessary for the map view, and most likely for precise reverse geocoding too,
- **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

View file

@ -77,6 +77,21 @@ android {
}
}
// the "splitting" dimension and its flavors are only for building purposes:
// NDK ABI filters are not compatible with split APK generation
// but we want to generate both a universal APK without x86 libs, and split APKs
flavorDimensions "splitting"
productFlavors {
universal {
dimension "splitting"
}
byAbi {
dimension "splitting"
}
}
buildTypes {
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'

View file

@ -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>

View file

@ -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

View file

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

View file

@ -15,19 +15,21 @@ import androidx.core.graphics.drawable.IconCompat
import app.loup.streams_channel.StreamsChannel
import 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

View file

@ -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()
}
}
}

View file

@ -22,7 +22,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
}
}
private fun areAnimationsRemoved(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var removed = false
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)
}

View file

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

View file

@ -53,7 +53,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
private fun getPackages(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val packages = HashMap<String, FieldMap>()
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())
}

View file

@ -60,7 +60,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
}
}
private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
private fun getContextDirs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
val dirs = hashMapOf(
"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())
}

View file

@ -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)
}

View file

@ -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"
}
}

View file

@ -11,6 +11,7 @@ import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
import deckers.thibault.aves.channel.calls.fetchers.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)
}

View file

@ -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 {

View file

@ -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"

View file

@ -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"
}

View file

@ -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)
}

View file

@ -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) {

View file

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

View file

@ -1,6 +1,6 @@
package deckers.thibault.aves.channel.streams
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
}

View file

@ -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"

View file

@ -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?>?
}
}

View file

@ -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?

View file

@ -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)

View file

@ -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
}
}

View file

@ -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,

View file

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

View file

@ -9,6 +9,7 @@ import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.file.FileTypeDirectory
import 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(

View file

@ -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
}
}
}

View file

@ -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
}

View file

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

View file

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

View file

@ -1,20 +1,20 @@
package deckers.thibault.aves.utils
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"

View file

@ -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)
}

View file

@ -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 {

View file

@ -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)

View file

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

View file

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

View file

@ -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>

View file

@ -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 &amp; videos</string>
<string name="analysis_notification_default_title">Scanning media</string>
<string name="analysis_notification_action_stop">Stop</string>
</resources>

View file

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

View file

@ -1,6 +1,6 @@
import 'dart:ui' as ui show Codec;
import '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');

View file

@ -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": {},

View file

@ -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{사진}}",

View file

@ -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();

View file

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

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:aves/model/entry.dart';
import 'package:aves/model/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');

View file

@ -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;

View file

@ -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:

View file

@ -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}';
}

View file

@ -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;
}

View file

@ -1,4 +1,4 @@
import 'package:aves/geo/format.dart';
import 'package:aves/utils/geo_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package: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(', ');
}

View file

@ -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;

View file

@ -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 }

View file

@ -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:

View file

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

View file

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

View file

@ -46,8 +46,8 @@ class CollectionLens with ChangeNotifier {
id ??= hashCode;
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));

View file

@ -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;

View file

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

View file

@ -4,6 +4,8 @@ import 'package:aves/geo/countries.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/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();

View file

@ -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();
}
}

View file

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

View file

@ -1,6 +1,7 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/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() {

View file

@ -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};

View file

@ -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',
};

View file

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

View file

@ -10,10 +10,35 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package: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;

View file

@ -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());

View file

@ -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 [];
}

View file

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

View file

@ -9,6 +9,7 @@ import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/output_buffer.dart';
import 'package:aves/services/common/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) {

View file

@ -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;
}

View file

@ -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 {

View file

@ -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;

View file

@ -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);

View file

@ -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));
}
}

View file

@ -19,6 +19,7 @@ import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/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) {

View file

@ -9,7 +9,7 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_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() {

View file

@ -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,

View file

@ -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));

View file

@ -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;

View file

@ -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),
),
],
);
);
},
),
],
);
}
}

View file

@ -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);
},

View file

@ -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);
}

View file

@ -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,
);
},
),

View file

@ -1,16 +1,20 @@
import 'package:aves/model/filters/coordinate.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/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);
}
}

View file

@ -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;

View file

@ -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());
}

View file

@ -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;

View file

@ -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());
}

View file

@ -1,5 +1,4 @@
import 'dart:math';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/widgets/common/basic/outlined_text.dart';
import 'package:aves/widgets/common/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);

View file

@ -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;

View file

@ -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