diff --git a/CHANGELOG.md b/CHANGELOG.md
index b63d076f8..5c904f43f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [v1.5.7] - 2021-12-01
+
+### Added
+
+- add and remove tags to JPEG/GIF/PNG/TIFF images
+- French translation
+- support for Android KitKat (without Google Maps)
+- Viewer: maximum brightness option
+
+### Changed
+
+- Settings: select hidden path directory with a custom file picker instead of the native SAF one
+- Viewer: video cover (before playing the video) is now loaded at original resolution and can be zoomed
+
+### Fixed
+
+- pinch-to-zoom gesture on thumbnails was difficult to trigger
+- double-tap gesture in the viewer was ignored in some cases
+- copied items had the wrong date
+
## [v1.5.6] - 2021-11-12
### Added
diff --git a/README.md b/README.md
index e86f34117..43e10ab02 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@ It scans your media collection to identify **motion photos**, **panoramas** (aka
**Navigation and search** is an important part of Aves. The goal is for users to easily flow from albums to photos to tags to maps, etc.
-Aves integrates with Android (from **API 20 to 31**, i.e. from Lollipop to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
+Aves integrates with Android (from **API 19 to 31**, i.e. from KitKat to S) with features such as **app shortcuts** and **global search** handling. It also works as a **media viewer and picker**.
## Screenshots
@@ -55,7 +55,7 @@ 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.
+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 are already handled.
### Donations
@@ -82,5 +82,10 @@ To run the app:
# flutter run -t lib/main_play.dart --flavor play
```
+To run the app on API 19 emulators:
+```
+# flutter run -t lib/main_play.dart --flavor play --enable-software-rendering
+```
+
[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
diff --git a/android/app/build.gradle b/android/app/build.gradle
index bccdc8a54..077647e2e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -55,9 +55,8 @@ android {
applicationId appId
// minSdkVersion constraints:
// - Flutter & other plugins: 16
- // - google_maps_flutter v2.0.5: 20
- // - Aves native: 19
- minSdkVersion 20
+ // - google_maps_flutter v2.1.1: 20
+ minSdkVersion 19
targetSdkVersion 31
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -149,7 +148,7 @@ dependencies {
// 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.deckerst:pixymeta-android:706bd73d6e'
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'androidx.annotation:annotation:1.3.0'
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0546ae2a0..90111d4fd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,21 +4,12 @@
android:installLocation="auto">
-
+
+
+
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt
index d4cfcb806..409f361bd 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisService.kt
@@ -23,7 +23,6 @@ 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
@@ -44,7 +43,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
val messenger = backgroundFlutterEngine!!.dartExecutor.binaryMessenger
// channels for analysis
- MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler())
+ MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
@@ -141,11 +140,12 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
getString(R.string.analysis_notification_action_stop),
stopServiceIntent
).build()
+ val icon = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) R.drawable.ic_notification else R.mipmap.ic_launcher_round
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)
+ .setSmallIcon(icon)
.setContentIntent(openAppIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.addAction(stopAction)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
index 64cba6a9c..d13ddcaaa 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
@@ -59,7 +59,7 @@ class MainActivity : FlutterActivity() {
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, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
@@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() {
DELETE_SINGLE_PERMISSION_REQUEST,
MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode)
CREATE_FILE_REQUEST,
- OPEN_FILE_REQUEST,
- SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data)
+ OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data)
}
}
@@ -164,11 +163,13 @@ class MainActivity : FlutterActivity() {
return
}
- // save access permissions across reboots
- val takeFlags = (data.flags
- and (Intent.FLAG_GRANT_READ_URI_PERMISSION
- or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
- contentResolver.takePersistableUriPermission(treeUri, takeFlags)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ // save access permissions across reboots
+ val takeFlags = (data.flags
+ and (Intent.FLAG_GRANT_READ_URI_PERMISSION
+ or Intent.FLAG_GRANT_WRITE_URI_PERMISSION))
+ contentResolver.takePersistableUriPermission(treeUri, takeFlags)
+ }
// resume pending action
onStorageAccessResult(requestCode, treeUri)
@@ -183,45 +184,45 @@ class MainActivity : FlutterActivity() {
private fun extractIntentData(intent: Intent?): MutableMap {
when (intent?.action) {
Intent.ACTION_MAIN -> {
- intent.getStringExtra("page")?.let { page ->
- var filters = intent.getStringArrayExtra("filters")?.toList()
+ intent.getStringExtra(SHORTCUT_KEY_PAGE)?.let { page ->
+ var filters = intent.getStringArrayExtra(SHORTCUT_KEY_FILTERS_ARRAY)?.toList()
if (filters == null) {
// fallback for shortcuts created on API < 26
- val filterString = intent.getStringExtra("filtersString")
+ val filterString = intent.getStringExtra(SHORTCUT_KEY_FILTERS_STRING)
if (filterString != null) {
filters = filterString.split(EXTRA_STRING_ARRAY_SEPARATOR)
}
}
return hashMapOf(
- "page" to page,
- "filters" to filters,
+ INTENT_DATA_KEY_PAGE to page,
+ INTENT_DATA_KEY_FILTERS to filters,
)
}
}
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri))?.let { uri ->
return hashMapOf(
- "action" to "view",
- "uri" to uri.toString(),
- "mimeType" to intent.type, // MIME type is optional
+ INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
+ INTENT_DATA_KEY_MIME_TYPE to intent.type, // MIME type is optional
+ INTENT_DATA_KEY_URI to uri.toString(),
)
}
}
Intent.ACTION_GET_CONTENT, Intent.ACTION_PICK -> {
return hashMapOf(
- "action" to "pick",
- "mimeType" to intent.type,
+ INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK,
+ INTENT_DATA_KEY_MIME_TYPE to intent.type,
)
}
Intent.ACTION_SEARCH -> {
val viewUri = intent.dataString
return if (viewUri != null) hashMapOf(
- "action" to "view",
- "uri" to viewUri,
- "mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
+ INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
+ INTENT_DATA_KEY_MIME_TYPE to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
+ INTENT_DATA_KEY_URI to viewUri,
) else hashMapOf(
- "action" to "search",
- "query" to intent.getStringExtra(SearchManager.QUERY),
+ INTENT_DATA_KEY_ACTION to INTENT_ACTION_SEARCH,
+ INTENT_DATA_KEY_QUERY to intent.getStringExtra(SearchManager.QUERY),
)
}
Intent.ACTION_RUN -> {
@@ -261,7 +262,7 @@ class MainActivity : FlutterActivity() {
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_search else R.drawable.ic_shortcut_search))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
- .putExtra("page", "/search")
+ .putExtra(SHORTCUT_KEY_PAGE, "/search")
)
.build()
@@ -270,7 +271,7 @@ class MainActivity : FlutterActivity() {
.setIcon(IconCompat.createWithResource(this, if (supportAdaptiveIcon) R.mipmap.ic_shortcut_movie else R.drawable.ic_shortcut_movie))
.setIntent(
Intent(Intent.ACTION_MAIN, null, this, MainActivity::class.java)
- .putExtra("page", "/collection")
+ .putExtra(SHORTCUT_KEY_PAGE, "/collection")
.putExtra("filters", arrayOf("{\"type\":\"mime\",\"mime\":\"video/*\"}"))
)
.build()
@@ -290,9 +291,23 @@ class MainActivity : FlutterActivity() {
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
+ const val DELETE_SINGLE_PERMISSION_REQUEST = 5
+ const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6
+
+ const val INTENT_DATA_KEY_ACTION = "action"
+ const val INTENT_DATA_KEY_FILTERS = "filters"
+ const val INTENT_DATA_KEY_MIME_TYPE = "mimeType"
+ const val INTENT_DATA_KEY_PAGE = "page"
+ const val INTENT_DATA_KEY_URI = "uri"
+ const val INTENT_DATA_KEY_QUERY = "query"
+
+ const val INTENT_ACTION_PICK = "pick"
+ const val INTENT_ACTION_SEARCH = "search"
+ const val INTENT_ACTION_VIEW = "view"
+
+ const val SHORTCUT_KEY_PAGE = "page"
+ const val SHORTCUT_KEY_FILTERS_ARRAY = "filters"
+ const val SHORTCUT_KEY_FILTERS_STRING = "filtersString"
// request code to pending runnable
val pendingStorageAccessResultHandlers = ConcurrentHashMap()
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt
index 77b5e015b..b18950cf0 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt
@@ -24,10 +24,12 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler {
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
- } catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get settings", e)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ try {
+ removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
+ }
}
result.success(removed)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt
index 8f0a263b5..b0d144906 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt
@@ -52,7 +52,7 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
}
// can be null or empty
- val contentIds = call.argument>("contentIds");
+ val contentIds = call.argument>("contentIds")
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
index 2d8dfe057..df244191f 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt
@@ -18,6 +18,10 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.MainActivity
+import deckers.thibault.aves.MainActivity.Companion.EXTRA_STRING_ARRAY_SEPARATOR
+import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_ARRAY
+import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_FILTERS_STRING
+import deckers.thibault.aves.MainActivity.Companion.SHORTCUT_KEY_PAGE
import deckers.thibault.aves.R
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@@ -47,8 +51,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
"openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share)
- "canPin" -> safe(call, result, ::canPin)
- "pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
+ "pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
else -> result.notImplemented()
}
}
@@ -59,7 +62,14 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
fun addPackageDetails(intent: Intent) {
// apps tend to use their name in English when creating directories
// so we get their names in English as well as the current locale
- val englishConfig = Configuration().apply { setLocale(Locale.ENGLISH) }
+ val englishConfig = Configuration().apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ setLocale(Locale.ENGLISH)
+ } else {
+ @Suppress("deprecation")
+ locale = Locale.ENGLISH
+ }
+ }
val pm = context.packageManager
for (resolveInfo in pm.queryIntentActivities(intent, 0)) {
@@ -319,13 +329,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// shortcuts
- private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
-
- private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
- result.success(isPinSupported())
- }
-
- private fun pin(call: MethodCall, result: MethodChannel.Result) {
+ private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) {
val label = call.argument("label")
val iconBytes = call.argument("iconBytes")
val filters = call.argument>("filters")
@@ -335,7 +339,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return
}
- if (!isPinSupported()) {
+ if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
result.error("pin-unsupported", "failed because the launcher does not support pinning shortcuts", null)
return
}
@@ -360,11 +364,11 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = when {
uri != null -> Intent(Intent.ACTION_VIEW, uri, context, MainActivity::class.java)
filters != null -> Intent(Intent.ACTION_MAIN, null, context, MainActivity::class.java)
- .putExtra("page", "/collection")
- .putExtra("filters", filters.toTypedArray())
+ .putExtra(SHORTCUT_KEY_PAGE, "/collection")
+ .putExtra(SHORTCUT_KEY_FILTERS_ARRAY, filters.toTypedArray())
// on API 25, `String[]` or `ArrayList` extras are null when using the shortcut
// so we use a joined `String` as fallback
- .putExtra("filtersString", filters.joinToString(MainActivity.EXTRA_STRING_ARRAY_SEPARATOR))
+ .putExtra(SHORTCUT_KEY_FILTERS_STRING, filters.joinToString(EXTRA_STRING_ARRAY_SEPARATOR))
else -> {
result.error("pin-intent", "failed to build intent", null)
return
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt
index 4240743d0..a4dcd071f 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt
@@ -1,21 +1,42 @@
package deckers.thibault.aves.channel.calls
+import android.content.Context
import android.os.Build
+import androidx.core.content.pm.ShortcutManagerCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import java.util.*
-class DeviceHandler : MethodCallHandler {
+class DeviceHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
+ "getCapabilities" -> safe(call, result, ::getCapabilities)
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
else -> result.notImplemented()
}
}
+ private fun getCapabilities(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
+ val sdkInt = Build.VERSION.SDK_INT
+ result.success(
+ hashMapOf(
+ "canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
+ "canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
+ "canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
+ "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
+ // as of google_maps_flutter v2.1.1, minSDK is 20 because of default PlatformView usage,
+ // but using hybrid composition would make it usable on API 19 too,
+ // cf https://github.com/flutter/flutter/issues/23728
+ "canRenderGoogleMaps" to (sdkInt >= Build.VERSION_CODES.KITKAT_WATCH),
+ "hasFilePicker" to (sdkInt >= Build.VERSION_CODES.KITKAT),
+ "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
+ )
+ )
+ }
+
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
result.success(TimeZone.getDefault().id)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
index 6ff4ef4b4..9040083d5 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
@@ -20,6 +20,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
+ "setIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setIptc) }
+ "setXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::setXmp) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
@@ -97,6 +99,64 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
})
}
+ private fun setIptc(call: MethodCall, result: MethodChannel.Result) {
+ val iptc = call.argument>("iptc")
+ val entryMap = call.argument("entry")
+ val postEditScan = call.argument("postEditScan")
+ if (entryMap == null || postEditScan == null) {
+ result.error("setIptc-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("setIptc-args", "failed because entry fields are missing", null)
+ return
+ }
+
+ val provider = getProvider(uri)
+ if (provider == null) {
+ result.error("setIptc-provider", "failed to find provider for uri=$uri", null)
+ return
+ }
+
+ provider.setIptc(activity, path, uri, mimeType, postEditScan, iptc = iptc, callback = object : ImageOpCallback {
+ override fun onSuccess(fields: FieldMap) = result.success(fields)
+ override fun onFailure(throwable: Throwable) = result.error("setIptc-failure", "failed to set IPTC for mimeType=$mimeType uri=$uri", throwable.message)
+ })
+ }
+
+ private fun setXmp(call: MethodCall, result: MethodChannel.Result) {
+ val xmp = call.argument("xmp")
+ val extendedXmp = call.argument("extendedXmp")
+ val entryMap = call.argument("entry")
+ if (entryMap == null) {
+ result.error("setXmp-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("setXmp-args", "failed because entry fields are missing", null)
+ return
+ }
+
+ val provider = getProvider(uri)
+ if (provider == null) {
+ result.error("setXmp-provider", "failed to find provider for uri=$uri", null)
+ return
+ }
+
+ provider.setXmp(activity, path, uri, mimeType, coreXmp = xmp, extendedXmp = extendedXmp, callback = object : ImageOpCallback {
+ override fun onSuccess(fields: FieldMap) = result.success(fields)
+ override fun onFailure(throwable: Throwable) = result.error("setXmp-failure", "failed to set XMP for mimeType=$mimeType uri=$uri", throwable.message)
+ })
+ }
+
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
val types = call.argument>("types")
val entryMap = call.argument("entry")
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
index 3fbbb707f..c1e4c2ae4 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
@@ -10,6 +10,8 @@ import android.provider.MediaStore
import android.util.Log
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
+import com.adobe.internal.xmp.XMPMetaFactory
+import com.adobe.internal.xmp.options.SerializeOptions
import com.adobe.internal.xmp.properties.XMPPropertyInfo
import com.drew.imaging.ImageMetadataReader
import com.drew.lang.KeyValuePair
@@ -71,6 +73,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
+import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.ParseException
import java.util.*
@@ -84,6 +87,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
+ "getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
+ "getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
else -> result.notImplemented()
@@ -185,7 +190,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
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 charset = if (baseDirName == PNG_ITXT_DIR_NAME) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ StandardCharsets.UTF_8
+ } else {
+ Charset.forName("UTF-8")
+ }
+ } else {
+ kv.value.charset
+ }
val valueString = String(kv.value.bytes, charset)
val dirs = extractPngProfile(key, valueString)
if (dirs?.any() == true) {
@@ -571,7 +584,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
var flags = (metadataMap[KEY_FLAGS] ?: 0) as Int
try {
- retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { metadataMap[KEY_ROTATION_DEGREES] = it }
+ }
if (!metadataMap.containsKey(KEY_DATE_MILLIS)) {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
}
@@ -621,16 +636,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
return
}
- val saveExposureTime: (value: Rational) -> Unit = {
+ val saveExposureTime = fun(value: Rational) {
// `TAG_EXPOSURE_TIME` as a string is sometimes a ratio, sometimes a decimal
// so we explicitly request it as a rational (e.g. 1/100, 1/14, 71428571/1000000000, 4000/1000, 2000000000/500000000)
// and process it to make sure the numerator is `1` when the ratio value is less than 1
- val num = it.numerator
- val denom = it.denominator
+ val num = value.numerator
+ val denom = value.denominator
metadataMap[KEY_EXPOSURE_TIME] = when {
- num >= denom -> "${it.toSimpleString(true)}″"
+ num >= denom -> "${value.toSimpleString(true)}″"
num != 1L && num != 0L -> Rational(1, (denom / num.toDouble()).roundToLong()).toString()
- else -> it.toString()
+ else -> value.toString()
}
}
@@ -734,6 +749,59 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.error("getPanoramaInfo-empty", "failed to read XMP for mimeType=$mimeType uri=$uri", null)
}
+ private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
+ val mimeType = call.argument("mimeType")
+ val uri = call.argument("uri")?.let { Uri.parse(it) }
+ if (mimeType == null || uri == null) {
+ result.error("getIptc-args", "failed because of missing arguments", null)
+ return
+ }
+
+ if (MimeTypes.canReadWithPixyMeta(mimeType)) {
+ try {
+ StorageUtils.openInputStream(context, uri)?.use { input ->
+ val iptcDataList = PixyMetaHelper.getIptc(input)
+ result.success(iptcDataList)
+ return
+ }
+ } catch (e: Exception) {
+ result.error("getIptc-exception", "failed to read IPTC for mimeType=$mimeType uri=$uri", e.message)
+ return
+ }
+ }
+
+ result.success(null)
+ }
+
+ private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
+ val mimeType = call.argument("mimeType")
+ val uri = call.argument("uri")?.let { Uri.parse(it) }
+ val sizeBytes = call.argument("sizeBytes")?.toLong()
+ if (mimeType == null || uri == null) {
+ result.error("getXmp-args", "failed because of missing arguments", null)
+ return
+ }
+
+ if (canReadWithMetadataExtractor(mimeType)) {
+ try {
+ Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
+ val metadata = ImageMetadataReader.readMetadata(input)
+ val xmpStrings = metadata.getDirectoriesOfType(XmpDirectory::class.java).mapNotNull { XMPMetaFactory.serializeToString(it.xmpMeta, xmpSerializeOptions) }
+ result.success(xmpStrings.toMutableList())
+ return
+ }
+ } catch (e: Exception) {
+ result.error("getXmp-exception", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
+ return
+ } catch (e: NoClassDefFoundError) {
+ result.error("getXmp-error", "failed to read XMP for mimeType=$mimeType uri=$uri", e.message)
+ return
+ }
+ }
+
+ result.success(null)
+ }
+
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument("prop")
if (prop == null) {
@@ -829,6 +897,11 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"XMP",
)
+ private val xmpSerializeOptions = SerializeOptions().apply {
+ omitPacketWrapper = true // e.g. ...
+ omitXmpMetaElement = false // e.g. ...
+ }
+
// catalog metadata
private const val KEY_MIME_TYPE = "mimeType"
private const val KEY_DATE_MILLIS = "dateMillis"
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt
index 2494977f9..528e70941 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt
@@ -45,7 +45,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
try {
locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0
} catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get settings", e)
+ Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
result.success(locked)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt
index 925dd90de..a4796a3c6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/RegionFetcher.kt
@@ -6,6 +6,7 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
+import android.os.Build
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
@@ -68,7 +69,12 @@ class RegionFetcher internal constructor(
if (currentDecoderRef == null) {
val newDecoder = StorageUtils.openInputStream(context, uri)?.use { input ->
@Suppress("BlockingMethodInNonBlockingContext")
- BitmapRegionDecoder.newInstance(input, false)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ BitmapRegionDecoder.newInstance(input)
+ } else {
+ @Suppress("deprecation")
+ BitmapRegionDecoder.newInstance(input, false)
+ }
}
if (newDecoder == null) {
result.error("getRegion-read-null", "failed to open file for uri=$uri regionRect=$regionRect", null)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt
index 26c15805a..4b53b693c 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/SettingsChangeStreamHandler.kt
@@ -3,6 +3,7 @@ package deckers.thibault.aves.channel.streams
import android.content.Context
import android.database.ContentObserver
import android.net.Uri
+import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
@@ -32,12 +33,13 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
override fun onChange(selfChange: Boolean, uri: Uri?) {
if (update()) {
- success(
- hashMapOf(
- Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
- Settings.Global.TRANSITION_ANIMATION_SCALE to transitionAnimationScale,
- )
+ val settings: FieldMap = hashMapOf(
+ Settings.System.ACCELEROMETER_ROTATION to accelerometerRotation,
)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ settings[Settings.Global.TRANSITION_ANIMATION_SCALE] = transitionAnimationScale
+ }
+ success(settings)
}
}
@@ -49,14 +51,15 @@ class SettingsChangeStreamHandler(private val context: Context) : EventChannel.S
accelerometerRotation = newAccelerometerRotation
changed = true
}
- val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
- if (transitionAnimationScale != newTransitionAnimationScale) {
- transitionAnimationScale = newTransitionAnimationScale
- changed = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ val newTransitionAnimationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE)
+ if (transitionAnimationScale != newTransitionAnimationScale) {
+ transitionAnimationScale = newTransitionAnimationScale
+ changed = true
+ }
}
-
} catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get settings", e)
+ Log.w(LOG_TAG, "failed to get settings with error=${e.message}", null)
}
return changed
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt
index 6586c5df8..35c564e73 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt
@@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
-import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
@@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
- "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
else -> endOfStream()
}
}
@@ -93,6 +91,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
}
private fun createFile() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+ // TODO TLAD [<=API18] create file
+ error("createFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
+ return
+ }
+
val name = args["name"] as String?
val mimeType = args["mimeType"] as String?
val bytes = args["bytes"] as ByteArray?
@@ -130,6 +134,12 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
private fun openFile() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
+ // TODO TLAD [<=API18] open file
+ error("openFile-sdk", "unsupported SDK version=${Build.VERSION.SDK_INT}", null)
+ return
+ }
+
val mimeType = args["mimeType"] as String?
if (mimeType == null) {
error("openFile-args", "failed because of missing arguments", null)
@@ -158,24 +168,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST)
}
- private fun selectDirectory() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
- val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
-
- MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
- success(StorageUtils.convertTreeUriToDirPath(activity, uri))
- endOfStream()
- }, {
- success(null)
- endOfStream()
- })
- activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST)
- } else {
- success(null)
- endOfStream()
- }
- }
-
override fun onCancel(arguments: Any?) {}
private fun success(result: Any?) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt
index c0bd10e44..4a7a048f6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/SvgGlideModule.kt
@@ -62,7 +62,7 @@ internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: I
val bitmapHeight: Int
if (width / height > svgWidth / svgHeight) {
bitmapWidth = ceil(svgWidth * height / svgHeight).toInt()
- bitmapHeight = height;
+ bitmapHeight = height
} else {
bitmapWidth = width
bitmapHeight = ceil(svgHeight * width / svgWidth).toInt()
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt
index 3817c7b38..102387552 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/ExifInterfaceHelper.kt
@@ -223,7 +223,9 @@ object ExifInterfaceHelper {
val dirs = DirType.values().map { Pair(it, it.createDirectory()) }.toMap()
// exclude Exif directory when it only includes image size
- val isUselessExif: (Map) -> Boolean = { it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width") }
+ val isUselessExif = fun(it: Map): Boolean {
+ return it.size == 2 && it.containsKey("Image Height") && it.containsKey("Image Width")
+ }
return HashMap>().apply {
put("Exif", describeDir(exif, dirs, baseTags).takeUnless(isUselessExif) ?: hashMapOf())
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt
index 1d04c0de9..4b2f04d16 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt
@@ -27,11 +27,13 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_NUM_TRACKS to "Number of Tracks",
MediaMetadataRetriever.METADATA_KEY_TITLE to "Title",
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT to "Video Height",
- MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION to "Video Rotation",
MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH to "Video Width",
MediaMetadataRetriever.METADATA_KEY_WRITER to "Writer",
MediaMetadataRetriever.METADATA_KEY_YEAR to "Year",
).apply {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ put(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION, "Video Rotation")
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
put(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE, "Capture Framerate")
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt
index 31d6adf4c..8e86a124e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/Metadata.kt
@@ -32,12 +32,12 @@ object Metadata {
const val DIR_PNG_TEXTUAL_DATA = "PNG Textual Data" // custom
// types of metadata
+ const val TYPE_COMMENT = "comment"
const val TYPE_EXIF = "exif"
const val TYPE_ICC_PROFILE = "icc_profile"
const val TYPE_IPTC = "iptc"
const val TYPE_JFIF = "jfif"
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
- const val TYPE_JPEG_COMMENT = "jpeg_comment"
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
const val TYPE_XMP = "xmp"
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
index 588927a9a..3cfa5f1bd 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
@@ -54,9 +54,11 @@ object MultiPage {
// do not use `MediaFormat.KEY_TRACK_ID` as it is actually not unique between tracks
// e.g. there could be both a video track and an image track with KEY_TRACK_ID == 1
- format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
format.getSafeInt(MediaFormat.KEY_WIDTH) { track[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { track[KEY_HEIGHT] = it }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
+ format.getSafeInt(MediaFormat.KEY_IS_DEFAULT) { track[KEY_IS_DEFAULT] = it != 0 }
+ }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { track[KEY_ROTATION_DEGREES] = it }
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt
index fb4df2a92..eee140f28 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/PixyMetaHelper.kt
@@ -1,17 +1,21 @@
package deckers.thibault.aves.metadata
+import deckers.thibault.aves.metadata.Metadata.TYPE_COMMENT
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
import deckers.thibault.aves.metadata.Metadata.TYPE_ICC_PROFILE
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
import deckers.thibault.aves.metadata.Metadata.TYPE_JFIF
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_ADOBE
-import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_COMMENT
import deckers.thibault.aves.metadata.Metadata.TYPE_JPEG_DUCKY
import deckers.thibault.aves.metadata.Metadata.TYPE_PHOTOSHOP_IRB
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
+import deckers.thibault.aves.model.FieldMap
import pixy.meta.meta.Metadata
import pixy.meta.meta.MetadataEntry
import pixy.meta.meta.MetadataType
+import pixy.meta.meta.iptc.IPTC
+import pixy.meta.meta.iptc.IPTCDataSet
+import pixy.meta.meta.iptc.IPTCRecord
import pixy.meta.meta.jpeg.JPGMeta
import pixy.meta.meta.xmp.XMP
import pixy.meta.string.XMLUtils
@@ -50,9 +54,46 @@ object PixyMetaHelper {
return metadataMap
}
+ fun getIptc(input: InputStream): List? {
+ val iptc = Metadata.readMetadata(input)[MetadataType.IPTC] as IPTC? ?: return null
+
+ val iptcDataList = ArrayList()
+ iptc.dataSets.forEach { dataSetEntry ->
+ val tag = dataSetEntry.key
+ val dataSets = dataSetEntry.value
+ iptcDataList.add(
+ hashMapOf(
+ "record" to tag.recordNumber,
+ "tag" to tag.tag,
+ "values" to dataSets.map { it.data }.toMutableList(),
+ )
+ )
+ }
+ return iptcDataList
+ }
+
+ fun setIptc(
+ input: InputStream,
+ output: OutputStream,
+ iptcDataList: List?,
+ ) {
+ val iptc = iptcDataList?.flatMap {
+ val record = it["record"] as Int
+ val tag = it["tag"] as Int
+ val values = it["values"] as List<*>
+ values.map { data -> IPTCDataSet(IPTCRecord.fromRecordNumber(record), tag, data as ByteArray) }
+ } ?: ArrayList()
+ Metadata.insertIPTC(input, output, iptc)
+ }
+
fun getXmp(input: InputStream): XMP? = Metadata.readMetadata(input)[MetadataType.XMP] as XMP?
- fun setXmp(input: InputStream, output: OutputStream, xmpString: String, extendedXmpString: String?) {
+ fun setXmp(
+ input: InputStream,
+ output: OutputStream,
+ xmpString: String?,
+ extendedXmpString: String?
+ ) {
if (extendedXmpString != null) {
JPGMeta.insertXMP(input, output, xmpString, extendedXmpString)
} else {
@@ -70,12 +111,12 @@ object PixyMetaHelper {
}
private fun toMetadataType(typeString: String): MetadataType? = when (typeString) {
+ TYPE_COMMENT -> MetadataType.COMMENT
TYPE_EXIF -> MetadataType.EXIF
TYPE_ICC_PROFILE -> MetadataType.ICC_PROFILE
TYPE_IPTC -> MetadataType.IPTC
TYPE_JFIF -> MetadataType.JPG_JFIF
TYPE_JPEG_ADOBE -> MetadataType.JPG_ADOBE
- TYPE_JPEG_COMMENT -> MetadataType.COMMENT
TYPE_JPEG_DUCKY -> MetadataType.JPG_DUCKY
TYPE_PHOTOSHOP_IRB -> MetadataType.PHOTOSHOP_IRB
TYPE_XMP -> MetadataType.XMP
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
index e649bfacf..72c38f447 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
@@ -23,7 +23,7 @@ object XMP {
private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/"
const val GCAMERA_SCHEMA_NS = "http://ns.google.com/photos/1.0/camera/"
private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/"
- const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
+ private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/"
const val CONTAINER_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/"
private const val CONTAINER_ITEM_SCHEMA_NS = "http://ns.google.com/photos/1.0/container/item/"
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
index 924c76a1d..cbba47781 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
@@ -5,6 +5,7 @@ import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
+import android.os.Build
import androidx.exifinterface.media.ExifInterface
import com.drew.imaging.ImageMetadataReader
import com.drew.metadata.avi.AviDirectory
@@ -135,10 +136,12 @@ class SourceEntry {
try {
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) { width = it }
retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) { height = it }
- retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
retriever.getSafeLong(MediaMetadataRetriever.METADATA_KEY_DURATION) { durationMillis = it }
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { sourceDateTakenMillis = it }
retriever.getSafeString(MediaMetadataRetriever.METADATA_KEY_TITLE) { title = it }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ retriever.getSafeInt(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION) { sourceRotationDegrees = it }
+ }
} catch (e: Exception) {
// ignore
} finally {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index e70aa3437..e74547232 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -27,6 +27,7 @@ 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.canEditIptc
import deckers.thibault.aves.utils.MimeTypes.canEditXmp
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
import deckers.thibault.aves.utils.MimeTypes.extensionFor
@@ -460,6 +461,94 @@ abstract class ImageProvider {
return true
}
+ private fun editIptc(
+ context: Context,
+ path: String,
+ uri: Uri,
+ mimeType: String,
+ callback: ImageOpCallback,
+ trailerDiff: Int = 0,
+ iptc: List?,
+ ): Boolean {
+ if (!canEditIptc(mimeType)) {
+ callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
+ return false
+ }
+
+ val originalFileSize = File(path).length()
+ val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
+ var videoBytes: ByteArray? = null
+ val editableFile = File.createTempFile("aves", null).apply {
+ deleteOnExit()
+ try {
+ outputStream().use { output ->
+ if (videoSize != null) {
+ // handle motion photo and embedded video separately
+ val imageSize = (originalFileSize - videoSize).toInt()
+ videoBytes = ByteArray(videoSize)
+
+ StorageUtils.openInputStream(context, uri)?.let { input ->
+ val imageBytes = ByteArray(imageSize)
+ input.read(imageBytes, 0, imageSize)
+ input.read(videoBytes, 0, videoSize)
+
+ // copy only the image to a temporary file for editing
+ // video will be appended after metadata modification
+ ByteArrayInputStream(imageBytes).use { imageInput ->
+ imageInput.copyTo(output)
+ }
+ }
+ } else {
+ // copy original file to a temporary file for editing
+ StorageUtils.openInputStream(context, uri)?.use { imageInput ->
+ imageInput.copyTo(output)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ callback.onFailure(e)
+ return false
+ }
+ }
+
+ try {
+ editableFile.outputStream().use { output ->
+ // reopen input to read from start
+ StorageUtils.openInputStream(context, uri)?.use { input ->
+ when {
+ iptc != null ->
+ PixyMetaHelper.setIptc(input, output, iptc)
+ canRemoveMetadata(mimeType) ->
+ PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
+ else -> {
+ Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
+ PixyMetaHelper.setIptc(input, output, null)
+ }
+ }
+ }
+ }
+
+ if (videoBytes != null) {
+ // append trailer video, if any
+ editableFile.appendBytes(videoBytes!!)
+ }
+
+ // copy the edited temporary file back to the original
+ copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
+
+ if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
+ return false
+ }
+ } catch (e: IOException) {
+ callback.onFailure(e)
+ return false
+ }
+
+ return true
+ }
+
+ // provide `editCoreXmp` to modify existing core XMP,
+ // or provide `coreXmp` and `extendedXmp` to set them
private fun editXmp(
context: Context,
path: String,
@@ -467,7 +556,9 @@ abstract class ImageProvider {
mimeType: String,
callback: ImageOpCallback,
trailerDiff: Int = 0,
- edit: (xmp: String) -> String,
+ coreXmp: String? = null,
+ extendedXmp: String? = null,
+ editCoreXmp: ((xmp: String) -> String)? = null,
): Boolean {
if (!canEditXmp(mimeType)) {
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
@@ -479,18 +570,34 @@ abstract class ImageProvider {
val editableFile = File.createTempFile("aves", null).apply {
deleteOnExit()
try {
- 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
+ var editedXmpString = coreXmp
+ var editedExtendedXmp = extendedXmp
+ if (editCoreXmp != null) {
+ val pixyXmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) }
+ if (pixyXmp != null) {
+ editedXmpString = editCoreXmp(pixyXmp.xmpDocString())
+ if (pixyXmp.hasExtendedXmp()) {
+ editedExtendedXmp = pixyXmp.extendedXmpDocString()
+ }
+ }
}
outputStream().use { output ->
// reopen input to read from start
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)
+ if (editedXmpString != null) {
+ if (editedExtendedXmp != null && mimeType != MimeTypes.JPEG) {
+ Log.w(LOG_TAG, "extended XMP is not supported by mimeType=$mimeType")
+ PixyMetaHelper.setXmp(input, output, editedXmpString, null)
+ } else {
+ PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
+ }
+ } else if (canRemoveMetadata(mimeType)) {
+ PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
+ } else {
+ Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
+ PixyMetaHelper.setXmp(input, output, null, null)
+ }
}
}
} catch (e: Exception) {
@@ -538,7 +645,7 @@ abstract class ImageProvider {
"We need to edit XMP to adjust trailer video offset by $diff bytes."
)
val newTrailerOffset = trailerOffset + diff
- return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff) { xmp ->
+ return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
xmp.replace(
// GCamera motion photo
"${XMP.GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$trailerOffset\"",
@@ -548,7 +655,7 @@ abstract class ImageProvider {
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$trailerOffset\"",
"${XMP.CONTAINER_ITEM_LENGTH_PROP_NAME}=\"$newTrailerOffset\"",
)
- }
+ })
}
fun editOrientation(
@@ -679,6 +786,65 @@ abstract class ImageProvider {
}
}
+ fun setIptc(
+ context: Context,
+ path: String,
+ uri: Uri,
+ mimeType: String,
+ postEditScan: Boolean,
+ callback: ImageOpCallback,
+ iptc: List? = null,
+ ) {
+ val newFields = HashMap()
+
+ val success = editIptc(
+ context = context,
+ path = path,
+ uri = uri,
+ mimeType = mimeType,
+ callback = callback,
+ iptc = iptc,
+ )
+
+ if (success) {
+ if (postEditScan) {
+ scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
+ } else {
+ callback.onSuccess(HashMap())
+ }
+ } else {
+ callback.onFailure(Exception("failed to set IPTC"))
+ }
+ }
+
+ fun setXmp(
+ context: Context,
+ path: String,
+ uri: Uri,
+ mimeType: String,
+ callback: ImageOpCallback,
+ coreXmp: String? = null,
+ extendedXmp: String? = null,
+ ) {
+ val newFields = HashMap()
+
+ val success = editXmp(
+ context = context,
+ path = path,
+ uri = uri,
+ mimeType = mimeType,
+ callback = callback,
+ coreXmp = coreXmp,
+ extendedXmp = extendedXmp,
+ )
+
+ if (success) {
+ scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
+ } else {
+ callback.onFailure(Exception("failed to set XMP"))
+ }
+ }
+
fun removeMetadataTypes(
context: Context,
path: String,
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
index aa7d090e9..be6731028 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
@@ -45,15 +45,15 @@ class MediaStoreImageProvider : ImageProvider() {
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
}
+ // the provided URI can point to the wrong media collection,
+ // e.g. a GIF image with the URI `content://media/external/video/media/[ID]`
+ // so the effective entry URI may not match the provided URI
override fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
var found = false
val fetched = arrayListOf()
val id = uri.tryParseId()
- val onSuccess = fun(entry: FieldMap) {
- entry["uri"] = uri.toString()
- fetched.add(entry)
- }
- val alwaysValid = { _: Int, _: Int -> true }
+ val alwaysValid: NewEntryChecker = fun(_: Int, _: Int): Boolean = true
+ val onSuccess: NewEntryHandler = fun(entry: FieldMap) { fetched.add(entry) }
if (id != null) {
if (!found && (sourceMimeType == null || isImage(sourceMimeType))) {
val contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, id)
@@ -83,7 +83,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
fun checkObsoleteContentIds(context: Context, knownContentIds: List): List {
- val foundContentIds = ArrayList()
+ val foundContentIds = HashSet()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID)
try {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt
index 973743105..afcf70c6e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/FlutterUtils.kt
@@ -23,7 +23,7 @@ object FlutterUtils {
}
lateinit var flutterLoader: FlutterLoader
- FlutterUtils.runOnUiThread {
+ runOnUiThread {
// initialization must happen on the main thread
flutterLoader = FlutterInjector.instance().flutterLoader().apply {
startInitialization(context)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
index 6416286b2..5e686fa9f 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
@@ -110,7 +110,16 @@ object MimeTypes {
}
// as of latest PixyMeta
- fun canEditXmp(mimeType: String) = canReadWithPixyMeta(mimeType)
+ fun canEditIptc(mimeType: String) = when (mimeType) {
+ JPEG, TIFF -> true
+ else -> false
+ }
+
+ // as of latest PixyMeta
+ fun canEditXmp(mimeType: String) = when (mimeType) {
+ JPEG, TIFF, PNG, GIF -> true
+ else -> false
+ }
// as of latest PixyMeta
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
index 32c40d1da..a078ea9a1 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
@@ -142,39 +142,6 @@ object PermissionManager {
}
}
- fun getRestrictedDirectories(context: Context): List