Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2025-02-06 17:19:03 +01:00
commit 7bf59106f9
130 changed files with 2095 additions and 1002 deletions

@ -1 +1 @@
Subproject commit 17025dd88227cd9532c33fa78f5250d548d87e9a Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863

View file

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with: with:
egress-policy: audit egress-policy: audit

View file

@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with: with:
egress-policy: audit egress-policy: audit
@ -52,14 +52,14 @@ jobs:
build-mode: manual build-mode: manual
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with: with:
egress-policy: audit egress-policy: audit
# Building relies on the Android Gradle plugin, # Building relies on the Android Gradle plugin,
# which requires a modern Java version (not the default one). # which requires a modern Java version (not the default one).
- name: Set up JDK for Android Gradle plugin - name: Set up JDK for Android Gradle plugin
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@ -69,7 +69,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@ -83,6 +83,6 @@ jobs:
./flutterw build apk --profile -t lib/main_play.dart --flavor play ./flutterw build apk --profile -t lib/main_play.dart --flavor play
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"

View file

@ -18,14 +18,14 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with: with:
egress-policy: audit egress-policy: audit
# Building relies on the Android Gradle plugin, # Building relies on the Android Gradle plugin,
# which requires a modern Java version (not the default one). # which requires a modern Java version (not the default one).
- name: Set up JDK for Android Gradle plugin - name: Set up JDK for Android Gradle plugin
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0 uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
@ -75,12 +75,12 @@ jobs:
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }} AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
- name: Generate artifact attestation - name: Generate artifact attestation
uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0 uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
with: with:
subject-path: 'outputs/*' subject-path: 'outputs/*'
- name: Create GitHub release - name: Create GitHub release
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0 uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
with: with:
artifacts: "outputs/*" artifacts: "outputs/*"
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})" body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
@ -98,7 +98,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with: with:
egress-policy: audit egress-policy: audit

View file

@ -31,7 +31,7 @@ jobs:
steps: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3 uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with: with:
egress-policy: audit egress-policy: audit
@ -71,6 +71,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard. # Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning" - name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0 uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with: with:
sarif_file: results.sarif sarif_file: results.sarif

View file

@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased] ## <a id="unreleased"></a>[Unreleased]
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06
### Added
- Metadata: edit location via GPX
- Metadata: toggle for all types in removal dialog
### Changed
- Viewer: improved subsampling and filter quality strategy
- Collection: ignore moving an item to its current directory
- Collection: keep selection when action on several items is interrupted before processing
- Collection: preserve favourite status when converting items
- upgraded Flutter to stable v3.27.4
### Fixed
- editing TIFF metadata increasing file size
- region decoding for some RAW files
- incorrect video size or orientation as reported by Media Store
- corrupting image when removing video from motion photo with incorrect metadata
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13 ## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
### Added ### Added

View file

@ -36,7 +36,7 @@ android {
namespace 'deckers.thibault.aves' namespace 'deckers.thibault.aves'
compileSdk 35 compileSdk 35
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp // cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
ndkVersion '27.0.12077973' ndkVersion '28.0.12916984'
defaultConfig { defaultConfig {
applicationId packageName applicationId packageName
@ -151,7 +151,7 @@ repositories {
} }
dependencies { dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
implementation "androidx.appcompat:appcompat:1.7.0" implementation "androidx.appcompat:appcompat:1.7.0"
implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.core:core-ktx:1.15.0'
@ -173,13 +173,13 @@ dependencies {
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory // - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
// - https://jitpack.io/p/deckerst/mp4parser // - https://jitpack.io/p/deckerst/mp4parser
// - https://jitpack.io/p/deckerst/pixymeta-android // - https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:Android-TiffBitmapFactory:90c06eebf4' implementation 'com.github.deckerst:Android-TiffBitmapFactory:3ed067f021'
implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd' implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd'
implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd' implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd'
implementation 'com.github.deckerst:pixymeta-android:9ec7097f17' implementation 'com.github.deckerst:pixymeta-android:71eee77dc4'
implementation project(':exifinterface') implementation project(':exifinterface')
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3' testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.4'
kapt 'androidx.annotation:annotation:1.9.1' kapt 'androidx.annotation:annotation:1.9.1'
ksp "com.github.bumptech.glide:ksp:$glide_version" ksp "com.github.bumptech.glide:ksp:$glide_version"

View file

@ -8,7 +8,6 @@ import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.content.res.Resources import android.content.res.Resources
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@ -16,6 +15,7 @@ import android.os.Looper
import android.util.Log import android.util.Log
import android.util.SizeF import android.util.SizeF
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.net.toUri
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
import deckers.thibault.aves.channel.calls.DeviceHandler import deckers.thibault.aves.channel.calls.DeviceHandler
@ -83,7 +83,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<FieldMap> { private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<SizeF> {
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java) widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@ -102,7 +102,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat())) sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
} }
return sizes.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) } return sizes
} }
private suspend fun getProps( private suspend fun getProps(
@ -116,13 +116,14 @@ class HomeWidgetProvider : AppWidgetProvider() {
if (sizesDip.isEmpty()) return null if (sizesDip.isEmpty()) return null
val sizeDip = sizesDip.first() val sizeDip = sizesDip.first()
if (sizeDip["widthDip"] == 0 || sizeDip["heightDip"] == 0) return null if (sizeDip.width == 0f || sizeDip.height == 0f) return null
val sizesDipMap = sizesDip.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val params = hashMapOf( val params = hashMapOf(
"widgetId" to widgetId, "widgetId" to widgetId,
"sizesDip" to sizesDip, "sizesDip" to sizesDipMap,
"devicePixelRatio" to getDevicePixelRatio(), "devicePixelRatio" to getDevicePixelRatio(),
"drawEntryImage" to drawEntryImage, "drawEntryImage" to drawEntryImage,
"reuseEntry" to reuseEntry, "reuseEntry" to reuseEntry,
@ -259,7 +260,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
} }
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent { private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, Uri.parse("widget://$widgetId"), context, HomeWidgetProvider::class.java) val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, "widget://$widgetId".toUri(), context, HomeWidgetProvider::class.java)
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId)) .putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
return PendingIntent.getBroadcast( return PendingIntent.getBroadcast(
@ -276,7 +277,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent { private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets // set a unique URI to prevent the intent (and its extras) from being shared by different widgets
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java) val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, "widget://$widgetId".toUri(), context, MainActivity::class.java)
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId) .putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
return PendingIntent.getActivity( return PendingIntent.getActivity(

View file

@ -69,6 +69,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import androidx.core.net.toUri
// `FlutterFragmentActivity` because of local auth plugin // `FlutterFragmentActivity` because of local auth plugin
open class MainActivity : FlutterFragmentActivity() { open class MainActivity : FlutterFragmentActivity() {
@ -442,7 +443,7 @@ open class MainActivity : FlutterFragmentActivity() {
return return
} }
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, Uri.parse(uriString)) } val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, uriString.toUri()) }
val intent = Intent().apply { val intent = Intent().apply {
val firstUri = toUri(pickedUris.first()) val firstUri = toUri(pickedUris.first())
if (pickedUris.size == 1) { if (pickedUris.size == 1) {

View file

@ -5,7 +5,15 @@ import android.util.Log
import android.view.View import android.view.View
import app.loup.streams_channel.StreamsChannel import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.AccessibilityHandler
import deckers.thibault.aves.channel.calls.DeviceHandler
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
import deckers.thibault.aves.channel.calls.MediaSessionHandler
import deckers.thibault.aves.channel.calls.MediaStoreHandler
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
import deckers.thibault.aves.channel.calls.StorageHandler
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
import deckers.thibault.aves.channel.calls.window.WindowHandler import deckers.thibault.aves.channel.calls.window.WindowHandler
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler import deckers.thibault.aves.channel.streams.ImageByteStreamHandler

View file

@ -16,8 +16,12 @@ import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.* import kotlinx.coroutines.CoroutineScope
import java.util.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.Locale
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine

View file

@ -7,6 +7,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.getParcelableExtraCompat import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import androidx.core.net.toUri
class WallpaperActivity : MainActivity() { class WallpaperActivity : MainActivity() {
private var originalIntent: String? = null private var originalIntent: String? = null
@ -39,7 +40,7 @@ class WallpaperActivity : MainActivity() {
if (originalIntent != null) { if (originalIntent != null) {
val pickedUris = call.argument<List<String>>("uris") val pickedUris = call.argument<List<String>>("uris")
if (!pickedUris.isNullOrEmpty()) { if (!pickedUris.isNullOrEmpty()) {
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) } val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
onNewIntent(Intent().apply { onNewIntent(Intent().apply {
action = originalIntent action = originalIntent
data = toUri(pickedUris.first()) data = toUri(pickedUris.first())

View file

@ -19,6 +19,7 @@ import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
@ -192,7 +193,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} }
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) { private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val label = call.argument<String>("label") val label = call.argument<String>("label")
if (uri == null) { if (uri == null) {
result.error("copyToClipboard-args", "missing arguments", null) result.error("copyToClipboard-args", "missing arguments", null)
@ -219,7 +220,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private fun open(call: MethodCall, result: MethodChannel.Result) { private fun open(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title") val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val forceChooser = call.argument<Boolean>("forceChooser") val forceChooser = call.argument<Boolean>("forceChooser")
if (uri == null || forceChooser == null) { if (uri == null || forceChooser == null) {
@ -236,7 +237,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
} }
private fun openMap(call: MethodCall, result: MethodChannel.Result) { private fun openMap(call: MethodCall, result: MethodChannel.Result) {
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) } val geoUri = call.argument<String>("geoUri")?.toUri()
if (geoUri == null) { if (geoUri == null) {
result.error("openMap-args", "missing arguments", null) result.error("openMap-args", "missing arguments", null)
return return
@ -250,7 +251,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private fun setAs(call: MethodCall, result: MethodChannel.Result) { private fun setAs(call: MethodCall, result: MethodChannel.Result) {
val title = call.argument<String>("title") val title = call.argument<String>("title")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
if (uri == null) { if (uri == null) {
result.error("setAs-args", "missing arguments", null) result.error("setAs-args", "missing arguments", null)
@ -273,7 +274,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return return
} }
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, Uri.parse(it)) }) val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, it.toUri()) })
val mimeTypes = urisByMimeType.keys.toTypedArray() val mimeTypes = urisByMimeType.keys.toTypedArray()
// simplify share intent for a single item, as some apps can handle one item but not more // simplify share intent for a single item, as some apps can handle one item but not more
@ -366,8 +367,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
// route dependent arguments // route dependent arguments
val filters = call.argument<List<String>>("filters") val filters = call.argument<List<String>>("filters")
val explorerPath = call.argument<String>("path") val explorerPath = call.argument<String>("path")
val viewUri = call.argument<String>("viewUri")?.let { Uri.parse(it) } val viewUri = call.argument<String>("viewUri")?.toUri()
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) } val geoUri = call.argument<String>("geoUri")?.toUri()
if (label == null || route == null) { if (label == null || route == null) {
result.error("pin-args", "missing arguments", null) result.error("pin-args", "missing arguments", null)

View file

@ -12,7 +12,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface import androidx.core.net.toUri
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper
@ -44,6 +44,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import org.mp4parser.IsoFile import org.mp4parser.IsoFile
import java.io.FileInputStream import java.io.FileInputStream
import java.io.IOException import java.io.IOException
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
class DebugHandler(private val context: Context) : MethodCallHandler { class DebugHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -127,7 +128,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (uri == null) { if (uri == null) {
result.error("getBitmapDecoderInfo-args", "missing arguments", null) result.error("getBitmapDecoderInfo-args", "missing arguments", null)
return return
@ -156,7 +157,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getContentResolverMetadata-args", "missing arguments", null) result.error("getContentResolverMetadata-args", "missing arguments", null)
return return
@ -212,7 +213,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getExifInterfaceMetadata-args", "missing arguments", null) result.error("getExifInterfaceMetadata-args", "missing arguments", null)
@ -239,7 +240,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (uri == null) { if (uri == null) {
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null) result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
return return
@ -264,7 +265,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) { private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getMetadataExtractorSummary-args", "missing arguments", null) result.error("getMetadataExtractorSummary-args", "missing arguments", null)
@ -308,7 +309,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) { private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getMp4ParserDump-args", "missing arguments", null) result.error("getMp4ParserDump-args", "missing arguments", null)
return return
@ -338,7 +339,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getPixyMetadata-args", "missing arguments", null) result.error("getPixyMetadata-args", "missing arguments", null)
return return
@ -359,7 +360,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
} }
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) { private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (uri == null) { if (uri == null) {
result.error("getTiffStructure-args", "missing arguments", null) result.error("getTiffStructure-args", "missing arguments", null)
return return

View file

@ -4,14 +4,15 @@ import android.app.LocaleConfig
import android.app.LocaleManager import android.app.LocaleManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Resources import android.content.res.Resources
import android.location.Geocoder import android.location.Geocoder
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.LocaleList import android.os.LocaleList
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.Settings import android.provider.Settings
import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toUri
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
@ -24,7 +25,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
import java.util.TimeZone
class DeviceHandler(private val context: Context) : MethodCallHandler { class DeviceHandler(private val context: Context) : MethodCallHandler {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -62,10 +62,17 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(), "isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q), "supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
"supportPictureInPicture" to supportPictureInPicture(),
) )
) )
} }
private fun supportPictureInPicture(): Boolean {
// minimum version for `PictureInPictureParams.Builder#setAutoEnterEnabled`
val supportPipOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
return supportPipOnLeave && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
}
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
fun toMap(locale: Locale): FieldMap = hashMapOf( fun toMap(locale: Locale): FieldMap = hashMapOf(
"language" to locale.language, "language" to locale.language,
@ -130,7 +137,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
return return
} }
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}")) val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, "package:${context.packageName}".toUri())
context.startActivity(intent) context.startActivity(intent)
result.success(true) result.success(true)
} }

View file

@ -1,9 +1,9 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException import com.adobe.internal.xmp.XMPException
import com.adobe.internal.xmp.XMPUtils import com.adobe.internal.xmp.XMPUtils
@ -18,6 +18,7 @@ import deckers.thibault.aves.metadata.xmp.GoogleDeviceContainer
import deckers.thibault.aves.metadata.xmp.GoogleXMP import deckers.thibault.aves.metadata.xmp.GoogleXMP
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
import deckers.thibault.aves.metadata.xmp.XMPPropName import deckers.thibault.aves.metadata.xmp.XMPPropName
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider import deckers.thibault.aves.model.provider.ImageProvider
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
@ -59,7 +60,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) { private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getExifThumbnails-args", "missing arguments", null) result.error("getExifThumbnails-args", "missing arguments", null)
@ -88,7 +89,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) { private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
val dataUri = call.argument<String>("dataUri") val dataUri = call.argument<String>("dataUri")
@ -143,7 +144,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) { private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
val id = call.argument<Int>("id") val id = call.argument<Int>("id")
@ -177,7 +178,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) { private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) { if (mimeType == null || uri == null || sizeBytes == null) {
@ -185,7 +186,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return return
} }
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes -> MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val imageSizeBytes = sizeBytes - videoSizeBytes val imageSizeBytes = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input -> StorageUtils.openInputStream(context, uri)?.let { input ->
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes) copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
@ -198,7 +199,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) { private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) { if (mimeType == null || uri == null || sizeBytes == null) {
@ -206,7 +207,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return return
} }
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes -> MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes val videoStartOffset = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input -> StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(videoStartOffset) input.skip(videoStartOffset)
@ -219,7 +220,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
} }
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) { private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
if (uri == null) { if (uri == null) {
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null) result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
@ -251,7 +252,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val displayName = call.argument<String>("displayName") val displayName = call.argument<String>("displayName")
val dataProp = call.argument<List<Any>>("propPath") val dataProp = call.argument<List<Any>>("propPath")
@ -329,8 +330,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
FileProvider.getUriForFile(context, authority, targetFile) FileProvider.getUriForFile(context, authority, targetFile)
} }
val resultFields: FieldMap = hashMapOf( val resultFields: FieldMap = hashMapOf(
"uri" to uri.toString(), EntryFields.URI to uri.toString(),
"mimeType" to mimeType, EntryFields.MIME_TYPE to mimeType,
) )
if (isImage(mimeType) || isVideo(mimeType)) { if (isImage(mimeType) || isVideo(mimeType)) {
val provider = getProvider(context, uri) val provider = getProvider(context, uri)

View file

@ -1,8 +1,8 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper import android.content.ContextWrapper
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
@ -44,7 +44,7 @@ class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallH
} }
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) { private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val desiredName = call.argument<String>("desiredName") val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap() val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes") val bytes = call.argument<ByteArray>("bytes")

View file

@ -2,7 +2,7 @@ package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
@ -68,7 +68,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
} }
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) { private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val pageId = call.argument<Int>("pageId") val pageId = call.argument<Int>("pageId")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.Context import android.content.Context
import android.net.Uri import androidx.core.net.toUri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
@ -28,7 +28,7 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
private fun getEntry(call: MethodCall, result: MethodChannel.Result) { private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") // MIME type is optional val mimeType = call.argument<String>("mimeType") // MIME type is optional
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
if (uri == null) { if (uri == null) {
result.error("getEntry-args", "missing arguments", null) result.error("getEntry-args", "missing arguments", null)

View file

@ -1,12 +1,16 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.* import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.media.AudioManager import android.media.AudioManager
import android.media.session.PlaybackState import android.media.session.PlaybackState
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.net.toUri
import androidx.media.session.MediaButtonReceiver import androidx.media.session.MediaButtonReceiver
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
@ -59,7 +63,7 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
} }
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) { private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val title = call.argument<String>("title") ?: uri?.toString() val title = call.argument<String>("title") ?: uri?.toString()
val durationMillis = call.argument<Number>("durationMillis")?.toLong() val durationMillis = call.argument<Number>("durationMillis")?.toLong()
val stateString = call.argument<String>("state") val stateString = call.argument<String>("state")

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper import android.content.ContextWrapper
import android.net.Uri import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.metadata.Mp4TooLargeException import deckers.thibault.aves.metadata.Mp4TooLargeException
import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.ExifOrientationOp
@ -54,7 +54,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
return return
} }
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { if (uri == null || path == null || mimeType == null) {
@ -82,7 +82,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
return return
} }
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { if (uri == null || path == null || mimeType == null) {
@ -109,7 +109,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
return return
} }
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { if (uri == null || path == null || mimeType == null) {
@ -134,7 +134,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
return return
} }
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { if (uri == null || path == null || mimeType == null) {
@ -160,7 +160,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
return return
} }
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val uri = (entryMap["uri"] as String?)?.toUri()
val path = entryMap["path"] as String? val path = entryMap["path"] as String?
val mimeType = entryMap["mimeType"] as String? val mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) { if (uri == null || path == null || mimeType == null) {

View file

@ -107,6 +107,7 @@ import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.math.roundToLong import kotlin.math.roundToLong
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
class MetadataFetchHandler(private val context: Context) : MethodCallHandler { class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -131,7 +132,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "missing arguments", null) result.error("getAllMetadata-args", "missing arguments", null)
@ -516,7 +517,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// - XMP / MicrosoftPhoto:Rating // - XMP / MicrosoftPhoto:Rating
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val path = call.argument<String>("path") val path = call.argument<String>("path")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
@ -869,7 +870,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) { private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val fields = call.argument<List<String>>("fields") val fields = call.argument<List<String>>("fields")
if (mimeType == null || uri == null || fields == null) { if (mimeType == null || uri == null || fields == null) {
@ -1000,7 +1001,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) { private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getGeoTiffInfo-args", "missing arguments", null) result.error("getGeoTiffInfo-args", "missing arguments", null)
@ -1041,7 +1042,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) { private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto") val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) { if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
@ -1068,7 +1069,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) { private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getPanoramaInfo-args", "missing arguments", null) result.error("getPanoramaInfo-args", "missing arguments", null)
@ -1120,7 +1121,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getIptc(call: MethodCall, result: MethodChannel.Result) { private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getIptc-args", "missing arguments", null) result.error("getIptc-args", "missing arguments", null)
return return
@ -1146,7 +1147,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// return an empty list if there is no XMP // return an empty list if there is no XMP
private fun getXmp(call: MethodCall, result: MethodChannel.Result) { private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) { if (mimeType == null || uri == null) {
result.error("getXmp-args", "missing arguments", null) result.error("getXmp-args", "missing arguments", null)
@ -1218,7 +1219,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) { private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val prop = call.argument<String>("prop") val prop = call.argument<String>("prop")
if (mimeType == null || uri == null || prop == null) { if (mimeType == null || uri == null || prop == null) {
result.error("getContentPropValue-args", "missing arguments", null) result.error("getContentPropValue-args", "missing arguments", null)
@ -1235,7 +1236,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getDate(call: MethodCall, result: MethodChannel.Result) { private fun getDate(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val field = call.argument<String>("field") val field = call.argument<String>("field")
if (mimeType == null || uri == null || field == null) { if (mimeType == null || uri == null || field == null) {
@ -1304,7 +1305,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getFields(call: MethodCall, result: MethodChannel.Result) { private fun getFields(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType") val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.toUri()
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong() val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val fields = call.argument<List<String>>("fields") val fields = call.argument<List<String>>("fields")
if (mimeType == null || uri == null || fields == null) { if (mimeType == null || uri == null || fields == null) {

View file

@ -6,14 +6,14 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder import android.graphics.BitmapRegionDecoder
import android.graphics.Rect import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import deckers.thibault.aves.decoder.AvesAppGlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MathUtils import deckers.thibault.aves.utils.MathUtils
import deckers.thibault.aves.utils.MemoryUtils import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -30,12 +30,7 @@ class RegionFetcher internal constructor(
) { ) {
private var lastDecoderRef: LastDecoderRef? = null private var lastDecoderRef: LastDecoderRef? = null
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>() private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
private val multiTrackGlideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
suspend fun fetch( suspend fun fetch(
uri: Uri, uri: Uri,
@ -45,25 +40,27 @@ class RegionFetcher internal constructor(
regionRect: Rect, regionRect: Rect,
imageWidth: Int, imageWidth: Int,
imageHeight: Int, imageHeight: Int,
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
result: MethodChannel.Result, result: MethodChannel.Result,
) { ) {
if (pageId != null && MultiPageImage.isSupported(mimeType)) { if (pageId != null && MultiPageImage.isSupported(mimeType)) {
val id = Pair(uri, pageId) // use JPEG export for requested page
fetch( fetch(
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) }, uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
mimeType = MimeTypes.JPEG, mimeType = MimeTypes.JPEG,
pageId = null, pageId = null,
sampleSize = sampleSize, sampleSize = sampleSize,
regionRect = regionRect, regionRect = regionRect,
imageWidth = imageWidth, imageWidth = imageWidth,
imageHeight = imageHeight, imageHeight = imageHeight,
requestKey = requestKey,
result = result, result = result,
) )
return return
} }
var currentDecoderRef = lastDecoderRef var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) { if (currentDecoderRef != null && currentDecoderRef.requestKey != requestKey) {
currentDecoderRef = null currentDecoderRef = null
} }
@ -76,7 +73,7 @@ class RegionFetcher internal constructor(
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null) result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
return return
} }
currentDecoderRef = LastDecoderRef(uri, newDecoder) currentDecoderRef = LastDecoderRef(requestKey, newDecoder)
} }
val decoder = currentDecoderRef.decoder val decoder = currentDecoderRef.decoder
lastDecoderRef = currentDecoderRef lastDecoderRef = currentDecoderRef
@ -119,16 +116,35 @@ class RegionFetcher internal constructor(
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null) result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
} }
} catch (e: Exception) { } catch (e: Exception) {
if (mimeType != MimeTypes.JPEG) {
// retry with JPEG export on failure,
// as some formats are not fully supported by `BitmapRegionDecoder`
fetch(
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
mimeType = MimeTypes.JPEG,
pageId = null,
sampleSize = sampleSize,
regionRect = regionRect,
imageWidth = imageWidth,
imageHeight = imageHeight,
requestKey = requestKey,
result = result,
)
return
}
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message) result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
} }
} }
private fun createJpegForPage(sourceUri: Uri, mimeType: String, pageId: Int): Uri { private fun createTemporaryJpegExport(uri: Uri, mimeType: String, pageId: Int?): Uri {
Log.d(LOG_TAG, "create JPEG export for uri=$uri mimeType=$mimeType pageId=$pageId")
val target = Glide.with(context) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(multiTrackGlideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(MultiPageImage(context, sourceUri, mimeType, pageId)) .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit() .submit()
try { try {
val bitmap = target.get() val bitmap = target.get()
val tempFile = StorageUtils.createTempFile(context).apply { val tempFile = StorageUtils.createTempFile(context).apply {
@ -143,7 +159,11 @@ class RegionFetcher internal constructor(
} }
private data class LastDecoderRef( private data class LastDecoderRef(
val uri: Uri, val requestKey: Pair<Uri, Int?>,
val decoder: BitmapRegionDecoder, val decoder: BitmapRegionDecoder,
) )
companion object {
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
}
} }

View file

@ -12,10 +12,8 @@ import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
@ -26,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.UriUtils.tryParseId import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
import androidx.core.net.toUri
class ThumbnailFetcher internal constructor( class ThumbnailFetcher internal constructor(
private val context: Context, private val context: Context,
@ -41,7 +40,7 @@ class ThumbnailFetcher internal constructor(
private val quality: Int, private val quality: Int,
private val result: MethodChannel.Result, private val result: MethodChannel.Result,
) { ) {
private val uri: Uri = Uri.parse(uri) private val uri: Uri = uri.toUri()
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
private val svgFetch = mimeType == SVG private val svgFetch = mimeType == SVG
@ -122,27 +121,15 @@ class ThumbnailFetcher internal constructor(
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565) .format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId")) .signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height) .override(width, height)
if (isVideo(mimeType)) {
val target = if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE) options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(context) }
val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(options) .apply(options)
.load(VideoThumbnail(context, uri)) .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit(width, height) .submit(width, height)
} else {
val model: Any = when {
svgFetch -> SvgImage(context, uri)
tiffFetch -> TiffImage(context, uri, pageId)
multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId)
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
}
Glide.with(context)
.asBitmap()
.apply(options)
.load(model)
.submit(width, height)
}
return try { return try {
var bitmap = target.get() var bitmap = target.get()

View file

@ -7,6 +7,7 @@ import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.channel.calls.AppAdapterHandler import deckers.thibault.aves.channel.calls.AppAdapterHandler
@ -71,7 +72,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
} }
private fun requestMediaFileAccess() { private fun requestMediaFileAccess() {
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null } val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) it.toUri() else null }
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null } val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) { if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
error("requestMediaFileAccess-args", "missing arguments", null) error("requestMediaFileAccess-args", "missing arguments", null)
@ -190,7 +191,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
val intent = Intent(Intent.ACTION_EDIT) val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setDataAndType(AppAdapterHandler.getShareableUri(activity, Uri.parse(uri)), mimeType) .setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
if (intent.resolveActivity(activity.packageManager) == null) { if (intent.resolveActivity(activity.packageManager) == null) {
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null) error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)

View file

@ -5,13 +5,9 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import deckers.thibault.aves.decoder.AvesAppGlideModule
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -85,7 +81,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
} }
val mimeType = arguments["mimeType"] as String? val mimeType = arguments["mimeType"] as String?
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) } val uri = (arguments["uri"] as String?)?.toUri()
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong() val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val rotationDegrees = arguments["rotationDegrees"] as Int val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean val isFlipped = arguments["isFlipped"] as Boolean
@ -130,18 +126,10 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
rotationDegrees: Int, rotationDegrees: Int,
isFlipped: Boolean, isFlipped: Boolean,
) { ) {
val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) {
MultiPageImage(context, uri, mimeType, pageId)
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(context, uri, pageId)
} else {
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
}
val target = Glide.with(context) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(model) .load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
.submit() .submit()
try { try {
var bitmap = withContext(Dispatchers.IO) { target.get() } var bitmap = withContext(Dispatchers.IO) { target.get() }
@ -159,7 +147,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null) error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
} }
} catch (e: Exception) { } catch (e: Exception) {
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e)) error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri", toErrorDetails(e))
} finally { } finally {
Glide.with(context).clear(target) Glide.with(context).clear(target)
} }
@ -168,8 +156,8 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) { private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
val target = Glide.with(context) val target = Glide.with(context)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(VideoThumbnail(context, uri)) .load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
.submit() .submit()
try { try {
val bitmap = withContext(Dispatchers.IO) { target.get() } val bitmap = withContext(Dispatchers.IO) { target.get() }
@ -218,11 +206,5 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
const val CHANNEL = "deckers.thibault/aves/media_byte_stream" const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
private const val BUFFER_SIZE = 2 shl 17 // 256kB private const val BUFFER_SIZE = 2 shl 17 // 256kB
// request a fresh image with the highest quality format
private val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
} }
} }

View file

@ -1,10 +1,10 @@
package deckers.thibault.aves.channel.streams package deckers.thibault.aves.channel.streams
import android.app.Activity import android.app.Activity
import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
@ -141,7 +141,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
// assume same provider for all entries // assume same provider for all entries
val firstEntry = entryMapList.first() val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(activity, it) } val provider = (firstEntry["uri"] as String?)?.toUri()?.let { getProvider(activity, it) }
if (provider == null) { if (provider == null) {
error("convert-provider", "failed to find provider for entry=$firstEntry", null) error("convert-provider", "failed to find provider for entry=$firstEntry", null)
return return

View file

@ -1,14 +1,21 @@
package deckers.thibault.aves.decoder package deckers.thibault.aves.decoder
import android.content.Context import android.content.Context
import android.net.Uri
import android.util.Log import android.util.Log
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.ImageHeaderParser import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.module.AppGlideModule
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.compatRemoveIf import deckers.thibault.aves.utils.compatRemoveIf
@GlideModule @GlideModule
@ -25,4 +32,26 @@ class AvesAppGlideModule : AppGlideModule() {
} }
override fun isManifestParsingEnabled(): Boolean = false override fun isManifestParsingEnabled(): Boolean = false
companion object {
// request a fresh image with the highest quality format
val uncachedFullImageOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any {
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
MultiPageImage(context, uri, mimeType, pageId)
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(context, uri, pageId)
} else if (mimeType == MimeTypes.SVG) {
SvgImage(context, uri)
} else if (isVideo(mimeType)) {
VideoThumbnail(context, uri)
} else {
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
}
}
}
} }

View file

@ -1,7 +1,6 @@
package deckers.thibault.aves.metadata package deckers.thibault.aves.metadata
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import com.drew.lang.Rational import com.drew.lang.Rational
import com.drew.metadata.Directory import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifDirectoryBase import com.drew.metadata.exif.ExifDirectoryBase
@ -19,6 +18,7 @@ import java.util.Locale
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.floor import kotlin.math.floor
import kotlin.math.roundToLong import kotlin.math.roundToLong
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
object ExifInterfaceHelper { object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>() private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()

View file

@ -111,20 +111,25 @@ object MediaMetadataRetrieverHelper {
// format // format
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION, MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°" MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH, MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels" MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
MediaMetadataRetriever.METADATA_KEY_BITRATE -> { MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
val bitrate = value.toLongOrNull() ?: 0 val bitrate = value.toLongOrNull() ?: 0
if (bitrate > 0) formatBitrate(bitrate) else null if (bitrate > 0) formatBitrate(bitrate) else null
} }
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> { MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
val framerate = value.toDoubleOrNull() ?: 0.0 val framerate = value.toDoubleOrNull() ?: 0.0
if (framerate > 0.0) "$framerate" else null if (framerate > 0.0) "$framerate" else null
} }
MediaMetadataRetriever.METADATA_KEY_DURATION -> { MediaMetadataRetriever.METADATA_KEY_DURATION -> {
val dateMillis = value.toLongOrNull() ?: 0 val dateMillis = value.toLongOrNull() ?: 0
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
} }
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> { MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
when (value.toIntOrNull()) { when (value.toIntOrNull()) {
MediaFormat.COLOR_RANGE_FULL -> "Full" MediaFormat.COLOR_RANGE_FULL -> "Full"
@ -132,6 +137,7 @@ object MediaMetadataRetrieverHelper {
else -> value else -> value
} }
} }
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> { MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
when (value.toIntOrNull()) { when (value.toIntOrNull()) {
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709" MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
@ -141,6 +147,7 @@ object MediaMetadataRetrieverHelper {
else -> value else -> value
} }
} }
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> { MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
when (value.toIntOrNull()) { when (value.toIntOrNull()) {
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear" MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
@ -154,6 +161,7 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_COMPILATION, MediaMetadataRetriever.METADATA_KEY_COMPILATION,
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER, MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
MediaMetadataRetriever.METADATA_KEY_DATE -> { MediaMetadataRetriever.METADATA_KEY_DATE -> {
val dateMillis = Metadata.parseVideoMetadataDate(value) val dateMillis = Metadata.parseVideoMetadataDate(value)
@ -168,4 +176,12 @@ object MediaMetadataRetrieverHelper {
}?.let { save(it) } }?.let { save(it) }
} }
} }
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
} }

View file

@ -9,7 +9,11 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.toByteArray import deckers.thibault.aves.utils.toByteArray
import deckers.thibault.aves.utils.toHex import deckers.thibault.aves.utils.toHex
import org.mp4parser.* import org.mp4parser.BasicContainer
import org.mp4parser.Box
import org.mp4parser.Container
import org.mp4parser.IsoFile
import org.mp4parser.PropertyBoxParserImpl
import org.mp4parser.boxes.UnknownBox import org.mp4parser.boxes.UnknownBox
import org.mp4parser.boxes.UserBox import org.mp4parser.boxes.UserBox
import org.mp4parser.boxes.apple.AppleCoverBox import org.mp4parser.boxes.apple.AppleCoverBox
@ -17,7 +21,16 @@ import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
import org.mp4parser.boxes.apple.AppleItemListBox import org.mp4parser.boxes.apple.AppleItemListBox
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
import org.mp4parser.boxes.apple.Utf8AppleDataBox import org.mp4parser.boxes.apple.Utf8AppleDataBox
import org.mp4parser.boxes.iso14496.part12.* import org.mp4parser.boxes.iso14496.part12.FreeBox
import org.mp4parser.boxes.iso14496.part12.HandlerBox
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
import org.mp4parser.boxes.iso14496.part12.MetaBox
import org.mp4parser.boxes.iso14496.part12.MovieBox
import org.mp4parser.boxes.iso14496.part12.MovieFragmentBox
import org.mp4parser.boxes.iso14496.part12.SampleTableBox
import org.mp4parser.boxes.iso14496.part12.SegmentIndexBox
import org.mp4parser.boxes.iso14496.part12.TrackHeaderBox
import org.mp4parser.boxes.iso14496.part12.UserDataBox
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
import org.mp4parser.support.AbstractBox import org.mp4parser.support.AbstractBox
import org.mp4parser.support.Matrix import org.mp4parser.support.Matrix

View file

@ -15,6 +15,8 @@ import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.xmp.XmpDirectory import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
@ -47,14 +49,6 @@ object MultiPage {
private const val KEY_ROTATION_DEGREES = "rotationDegrees" private const val KEY_ROTATION_DEGREES = "rotationDegrees"
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> { fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
val tracks = ArrayList<FieldMap>() val tracks = ArrayList<FieldMap>()
val extractor = MediaExtractor() val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null) extractor.setDataSource(context, uri, null)
@ -250,23 +244,9 @@ object MultiPage {
} }
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> { fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
if (this.containsKey(key)) save(this.getInteger(key))
}
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
if (this.containsKey(key)) save(this.getLong(key))
}
val pages = ArrayList<FieldMap>() val pages = ArrayList<FieldMap>()
val extractor = MediaExtractor() getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
var pfd: ParcelFileDescriptor? = null getTrailerVideoInfo(context, uri, fileSizeBytes = sizeBytes, videoSizeBytes = videoSizeBytes)?.let { videoInfo ->
try {
getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes
pfd = context.contentResolver.openFileDescriptor(uri, "r")
pfd?.fileDescriptor?.let { fd ->
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
// set the original image as the first and default track // set the original image as the first and default track
var pageIndex = 0 var pageIndex = 0
pages.add( pages.add(
@ -277,43 +257,28 @@ object MultiPage {
) )
) )
// add video tracks from the appended video // add video tracks from the appended video
if (extractor.trackCount > 0) { videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
// only consider the first track to represent the appended video
val trackIndex = 0
try {
val format = extractor.getTrackFormat(trackIndex)
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) { if (MimeTypes.isVideo(mime)) {
val page: FieldMap = hashMapOf( val page: FieldMap = hashMapOf(
KEY_PAGE to pageIndex++, KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to MimeTypes.MP4, KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false, KEY_IS_DEFAULT to false,
) )
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it } videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it } videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
format.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it } videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
} }
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 } videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
pages.add(page) pages.add(page)
} }
} }
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
} }
} }
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
} finally {
extractor.release()
pfd?.close()
}
return pages return pages
} }
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? { fun getMotionPhotoVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
if (MimeTypes.isHeic(mimeType)) { if (MimeTypes.isHeic(mimeType)) {
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video. // XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec), // This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
@ -360,6 +325,34 @@ object MultiPage {
return offsetFromEnd return offsetFromEnd
} }
fun getTrailerVideoInfo(context: Context, uri: Uri, fileSizeBytes: Long, videoSizeBytes: Long): MediaFormat? {
var format: MediaFormat? = null
val extractor = MediaExtractor()
var pfd: ParcelFileDescriptor? = null
try {
val videoStartOffset = fileSizeBytes - videoSizeBytes
pfd = context.contentResolver.openFileDescriptor(uri, "r")
pfd?.fileDescriptor?.let { fd ->
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
if (extractor.trackCount > 0) {
// only consider the first track to represent the appended video
val trackIndex = 0
try {
format = extractor.getTrackFormat(trackIndex)
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
}
}
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
} finally {
extractor.release()
pfd?.close()
}
return format
}
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> { fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap { fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
return hashMapOf( return hashMapOf(

View file

@ -26,7 +26,6 @@ import pixy.meta.string.XMLUtils
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.*
object PixyMetaHelper { object PixyMetaHelper {
fun describe(input: InputStream): HashMap<String, String> { fun describe(input: InputStream): HashMap<String, String> {

View file

@ -3,7 +3,6 @@ package deckers.thibault.aves.metadata
import deckers.thibault.aves.utils.toHex import deckers.thibault.aves.utils.toHex
import java.math.BigInteger import java.math.BigInteger
import java.nio.charset.Charset import java.nio.charset.Charset
import java.util.*
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String) class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)

View file

@ -183,7 +183,7 @@ object GoogleXMP {
return offsetFromEnd return offsetFromEnd
} }
fun updateTrailingVideoOffset(xmp: String, oldOffset: Int, newOffset: Int): String { fun updateTrailingVideoOffset(xmp: String, oldOffset: Number, newOffset: Number): String {
return xmp.replace( return xmp.replace(
// GCamera motion photo // GCamera motion photo
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"", "${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
@ -195,7 +195,6 @@ object GoogleXMP {
) )
} }
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? { fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) { return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
GoogleDeviceContainer().apply { findItems(meta) } GoogleDeviceContainer().apply { findItems(meta) }

View file

@ -1,19 +1,20 @@
package deckers.thibault.aves.model package deckers.thibault.aves.model
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri
class AvesEntry(map: FieldMap) { class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI val uri: Uri = (map[EntryFields.URI] as String).toUri() // content or file URI
val path = map["path"] as String? // best effort to get local path val path = map[EntryFields.PATH] as String? // best effort to get local path
val pageId = map["pageId"] as Int? // null means the main entry val pageId = map[EntryFields.PAGE_ID] as Int? // null means the main entry
val mimeType = map["mimeType"] as String val mimeType = map[EntryFields.MIME_TYPE] as String
val width = map["width"] as Int val width = map[EntryFields.WIDTH] as Int
val height = map["height"] as Int val height = map[EntryFields.HEIGHT] as Int
val rotationDegrees = map["rotationDegrees"] as Int val rotationDegrees = map[EntryFields.ROTATION_DEGREES] as Int
val isFlipped = map["isFlipped"] as Boolean val isFlipped = map[EntryFields.IS_FLIPPED] as Boolean
val sizeBytes = toLong(map["sizeBytes"]) val sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
val trashed = map["trashed"] as Boolean val trashed = map[EntryFields.TRASHED] as Boolean
val trashPath = map["trashPath"] as String? val trashPath = map[EntryFields.TRASH_PATH] as String?
private val isRotated: Boolean private val isRotated: Boolean
get() = rotationDegrees % 180 == 90 get() = rotationDegrees % 180 == 90

View file

@ -0,0 +1,29 @@
package deckers.thibault.aves.model
// entry fields exported and imported from/to the platform side
// should match `EntryFields` on Dart side
object EntryFields {
const val ORIGIN = "origin" // int
const val URI = "uri" // string
const val CONTENT_ID = "contentId" // long
const val PATH = "path" // string
const val PAGE_ID = "pageId" // int
const val SOURCE_MIME_TYPE = "sourceMimeType" // string
const val MIME_TYPE = "mimeType" // string
const val WIDTH = "width" // int
const val HEIGHT = "height" // int
const val SOURCE_ROTATION_DEGREES = "sourceRotationDegrees" // int
const val ROTATION_DEGREES = "rotationDegrees" // int
const val IS_FLIPPED = "isFlipped" // boolean
const val DATE_ADDED_SECS = "dateAddedSecs" // long
const val DATE_MODIFIED_SECS = "dateModifiedSecs" // long
const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long
const val DURATION_MILLIS = "durationMillis" // long
const val SIZE_BYTES = "sizeBytes" // long
const val TRASHED = "trashed" // boolean
const val TRASH_PATH = "trashPath" // string
const val TITLE = "title" // string
}

View file

@ -29,6 +29,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import org.beyka.tiffbitmapfactory.TiffBitmapFactory import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException import java.io.IOException
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
class SourceEntry { class SourceEntry {
private val origin: Int private val origin: Int
@ -54,19 +55,19 @@ class SourceEntry {
} }
constructor(map: FieldMap) { constructor(map: FieldMap) {
origin = map["origin"] as Int origin = map[EntryFields.ORIGIN] as Int
uri = Uri.parse(map["uri"] as String) uri = (map[EntryFields.URI] as String).toUri()
path = map["path"] as String? path = map[EntryFields.PATH] as String?
sourceMimeType = map["sourceMimeType"] as String sourceMimeType = map[EntryFields.SOURCE_MIME_TYPE] as String
width = map["width"] as Int? width = map[EntryFields.WIDTH] as Int?
height = map["height"] as Int? height = map[EntryFields.HEIGHT] as Int?
sourceRotationDegrees = map["sourceRotationDegrees"] as Int? sourceRotationDegrees = map[EntryFields.SOURCE_ROTATION_DEGREES] as Int?
sizeBytes = toLong(map["sizeBytes"]) sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
title = map["title"] as String? title = map[EntryFields.TITLE] as String?
dateAddedSecs = toLong(map["dateAddedSecs"]) dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
dateModifiedSecs = toLong(map["dateModifiedSecs"]) dateModifiedSecs = toLong(map[EntryFields.DATE_MODIFIED_SECS])
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"]) sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
durationMillis = toLong(map["durationMillis"]) durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
} }
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) { fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
@ -78,21 +79,21 @@ class SourceEntry {
fun toMap(): FieldMap { fun toMap(): FieldMap {
return hashMapOf( return hashMapOf(
"origin" to origin, EntryFields.ORIGIN to origin,
"uri" to uri.toString(), EntryFields.URI to uri.toString(),
"path" to path, EntryFields.PATH to path,
"sourceMimeType" to sourceMimeType, EntryFields.SOURCE_MIME_TYPE to sourceMimeType,
"width" to width, EntryFields.WIDTH to width,
"height" to height, EntryFields.HEIGHT to height,
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0), EntryFields.SOURCE_ROTATION_DEGREES to (sourceRotationDegrees ?: 0),
"sizeBytes" to sizeBytes, EntryFields.SIZE_BYTES to sizeBytes,
"title" to title, EntryFields.TITLE to title,
"dateAddedSecs" to dateAddedSecs, EntryFields.DATE_ADDED_SECS to dateAddedSecs,
"dateModifiedSecs" to dateModifiedSecs, EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
"sourceDateTakenMillis" to sourceDateTakenMillis, EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
"durationMillis" to durationMillis, EntryFields.DURATION_MILLIS to durationMillis,
// only for map export // only for map export
"contentId" to contentId, EntryFields.CONTENT_ID to contentId,
) )
} }

View file

@ -6,6 +6,7 @@ import android.content.ContextWrapper
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -88,9 +89,9 @@ internal class FileImageProvider : ImageProvider() {
} }
return hashMapOf( return hashMapOf(
"uri" to Uri.fromFile(newFile).toString(), EntryFields.URI to Uri.fromFile(newFile).toString(),
"path" to newFile.path, EntryFields.PATH to newFile.path,
"dateModifiedSecs" to newFile.lastModified() / 1000, EntryFields.DATE_MODIFIED_SECS to newFile.lastModified() / 1000,
) )
} }
@ -98,8 +99,8 @@ internal class FileImageProvider : ImageProvider() {
try { try {
val file = File(path) val file = File(path)
if (file.exists()) { if (file.exists()) {
newFields["dateModifiedSecs"] = file.lastModified() / 1000 newFields[EntryFields.DATE_MODIFIED_SECS] = file.lastModified() / 1000
newFields["sizeBytes"] = file.length() newFields[EntryFields.SIZE_BYTES] = file.length()
} }
callback.onSuccess(newFields) callback.onSuccess(newFields)
} catch (e: SecurityException) { } catch (e: SecurityException) {

View file

@ -11,16 +11,10 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.FutureTarget import com.bumptech.glide.request.FutureTarget
import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.ExifInterfaceHelper import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
@ -68,6 +62,9 @@ import java.nio.channels.Channels
import java.util.Date import java.util.Date
import java.util.TimeZone import java.util.TimeZone
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
import deckers.thibault.aves.model.EntryFields
abstract class ImageProvider { abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) { open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
@ -78,10 +75,10 @@ abstract class ImageProvider {
return if (StorageUtils.isInVault(context, path)) { return if (StorageUtils.isInVault(context, path)) {
val uri = Uri.fromFile(File(path)) val uri = Uri.fromFile(File(path))
hashMapOf( hashMapOf(
"origin" to SourceEntry.ORIGIN_VAULT, EntryFields.ORIGIN to SourceEntry.ORIGIN_VAULT,
"uri" to uri.toString(), EntryFields.URI to uri.toString(),
"contentId" to null, EntryFields.CONTENT_ID to null,
"path" to path, EntryFields.PATH to path,
) )
} else { } else {
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType) MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
@ -317,27 +314,12 @@ abstract class ImageProvider {
} }
} }
val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) {
MultiPageImage(activity, sourceUri, sourceMimeType, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.SVG) {
SvgImage(activity, sourceUri)
} else {
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType, sourceEntry.sizeBytes)
}
// request a fresh image with the highest quality format
val glideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
target = Glide.with(activity.applicationContext) target = Glide.with(activity.applicationContext)
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(model) .load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
.submit(targetWidthPx, targetHeightPx) .submit(targetWidthPx, targetHeightPx)
var bitmap = withContext(Dispatchers.IO) { target.get() } var bitmap = withContext(Dispatchers.IO) { target.get() }
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) { if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped) bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
@ -380,7 +362,7 @@ abstract class ImageProvider {
) )
val newFields = scanNewPath(activity, targetPath, exportMimeType) val newFields = scanNewPath(activity, targetPath, exportMimeType)
val targetUri = Uri.parse(newFields["uri"] as String) val targetUri = (newFields[EntryFields.URI] as String).toUri()
if (writeMetadata) { if (writeMetadata) {
copyMetadata( copyMetadata(
context = activity, context = activity,
@ -664,19 +646,21 @@ abstract class ImageProvider {
} }
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } var trailerVideoBytes: ByteArray? = null
var videoBytes: ByteArray? = null
val editableFile = StorageUtils.createTempFile(context).apply { val editableFile = StorageUtils.createTempFile(context).apply {
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
try { try {
if (videoSize != null) { if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately // handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt() val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize) val videoByteSize = videoSize.toInt()
trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input -> StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSize) val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize) input.read(imageBytes, 0, imageSize)
input.read(videoBytes, 0, videoSize) input.read(trailerVideoBytes, 0, videoByteSize)
// copy only the image to a temporary file for editing // copy only the image to a temporary file for editing
// video will be appended after metadata modification // video will be appended after metadata modification
@ -711,15 +695,15 @@ abstract class ImageProvider {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile)) ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
} }
if (videoBytes != null) { if (trailerVideoBytes != null) {
// append trailer video, if any // append trailer video, if any
editableFile.appendBytes(videoBytes!!) editableFile.appendBytes(trailerVideoBytes!!)
} }
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path)) editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
return false return false
} }
editableFile.delete() editableFile.delete()
@ -747,19 +731,21 @@ abstract class ImageProvider {
} }
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } var trailerVideoBytes: ByteArray? = null
var videoBytes: ByteArray? = null
val editableFile = StorageUtils.createTempFile(context).apply { val editableFile = StorageUtils.createTempFile(context).apply {
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
try { try {
if (videoSize != null) { if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately // handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt() val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize) val videoByteSize = videoSize.toInt()
trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input -> StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSize) val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize) input.read(imageBytes, 0, imageSize)
input.read(videoBytes, 0, videoSize) input.read(trailerVideoBytes, 0, videoByteSize)
// copy only the image to a temporary file for editing // copy only the image to a temporary file for editing
// video will be appended after metadata modification // video will be appended after metadata modification
@ -795,15 +781,15 @@ abstract class ImageProvider {
} }
} }
if (videoBytes != null) { if (trailerVideoBytes != null) {
// append trailer video, if any // append trailer video, if any
editableFile.appendBytes(videoBytes!!) editableFile.appendBytes(trailerVideoBytes!!)
} }
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path)) editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
return false return false
} }
editableFile.delete() editableFile.delete()
@ -913,7 +899,7 @@ abstract class ImageProvider {
} }
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = StorageUtils.createTempFile(context).apply { val editableFile = StorageUtils.createTempFile(context).apply {
try { try {
editXmpWithPixy( editXmpWithPixy(
@ -996,7 +982,7 @@ abstract class ImageProvider {
path: String, path: String,
uri: Uri, uri: Uri,
mimeType: String, mimeType: String,
trailerOffset: Int?, trailerOffset: Number?,
editedFile: File, editedFile: File,
callback: ImageOpCallback, callback: ImageOpCallback,
): Boolean { ): Boolean {
@ -1011,7 +997,7 @@ abstract class ImageProvider {
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " + LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
"We need to edit XMP to adjust trailer video offset by $diff bytes." "We need to edit XMP to adjust trailer video offset by $diff bytes."
) )
val newTrailerOffset = trailerOffset + diff val newTrailerOffset = trailerOffset.toLong() + diff
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp -> return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset) GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
}) })
@ -1276,12 +1262,18 @@ abstract class ImageProvider {
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
if (videoSize == null) { if (videoSize == null) {
callback.onFailure(Exception("failed to get trailer video size")) callback.onFailure(Exception("failed to get trailer video size"))
return return
} }
val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSizeBytes = originalFileSize, videoSizeBytes = videoSize) != null
if (!isTrailerVideoValid) {
callback.onFailure(Exception("failed to open trailer video with size=$videoSize"))
return
}
val editableFile = StorageUtils.createTempFile(context).apply { val editableFile = StorageUtils.createTempFile(context).apply {
try { try {
val inputStream = StorageUtils.openInputStream(context, uri) val inputStream = StorageUtils.openInputStream(context, uri)
@ -1321,7 +1313,8 @@ abstract class ImageProvider {
} }
val originalFileSize = File(path).length() val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
val editableFile = StorageUtils.createTempFile(context).apply { val editableFile = StorageUtils.createTempFile(context).apply {
try { try {
outputStream().use { output -> outputStream().use { output ->
@ -1341,7 +1334,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original // copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path)) editableFile.transferTo(outputStream(context, mimeType, uri, path))
if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return return
} }
editableFile.delete() editableFile.delete()

View file

@ -20,6 +20,7 @@ import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.AvesEntry
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
@ -40,7 +41,6 @@ import java.io.FileOutputStream
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.io.SyncFailedException import java.io.SyncFailedException
import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletableFuture
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
@ -77,7 +77,7 @@ class MediaStoreImageProvider : ImageProvider() {
val parentCheckDirectory = removeTrailingSeparator(directory) val parentCheckDirectory = removeTrailingSeparator(directory)
handleNew = { entry -> handleNew = { entry ->
// skip entries in subfolders // skip entries in subfolders
val path = entry["path"] as String? val path = entry[EntryFields.PATH] as String?
if (path != null && File(path).parent == parentCheckDirectory) { if (path != null && File(path).parent == parentCheckDirectory) {
handleNewEntry(entry) handleNewEntry(entry)
} }
@ -256,20 +256,20 @@ class MediaStoreImageProvider : ImageProvider() {
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type") Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
} else { } else {
var entryMap: FieldMap = hashMapOf( var entryMap: FieldMap = hashMapOf(
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT, EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
"uri" to itemUri.toString(), EntryFields.URI to itemUri.toString(),
"path" to cursor.getString(pathColumn), EntryFields.PATH to cursor.getString(pathColumn),
"sourceMimeType" to mimeType, EntryFields.SOURCE_MIME_TYPE to mimeType,
"width" to width, EntryFields.WIDTH to width,
"height" to height, EntryFields.HEIGHT to height,
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0, EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
"sizeBytes" to cursor.getLong(sizeColumn), EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn),
"dateAddedSecs" to cursor.getInt(dateAddedColumn), EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedColumn),
"dateModifiedSecs" to dateModifiedSecs, EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null, EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
"durationMillis" to durationMillis, EntryFields.DURATION_MILLIS to durationMillis,
// only for map export // only for map export
"contentId" to id, EntryFields.CONTENT_ID to id,
) )
if (MimeTypes.isHeic(mimeType)) { if (MimeTypes.isHeic(mimeType)) {
@ -285,8 +285,8 @@ class MediaStoreImageProvider : ImageProvider() {
if (outWidth > 0 && outHeight > 0) { if (outWidth > 0 && outHeight > 0) {
width = outWidth width = outWidth
height = outHeight height = outHeight
entryMap["width"] = width entryMap[EntryFields.WIDTH] = width
entryMap["height"] = height entryMap[EntryFields.HEIGHT] = height
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
@ -598,8 +598,8 @@ class MediaStoreImageProvider : ImageProvider() {
} }
return if (toBin) { return if (toBin) {
hashMapOf( hashMapOf(
"trashed" to true, EntryFields.TRASHED to true,
"trashPath" to targetPath, EntryFields.TRASH_PATH to targetPath,
) )
} else { } else {
scanNewPath(activity, targetPath, mimeType) scanNewPath(activity, targetPath, mimeType)
@ -912,13 +912,13 @@ class MediaStoreImageProvider : ImageProvider() {
val cursor = context.contentResolver.query(uri, projection, null, null, null) val cursor = context.contentResolver.query(uri, projection, null, null, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
val newFields = hashMapOf<String, Any?>( val newFields = hashMapOf<String, Any?>(
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT, EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
"uri" to uri.toString(), EntryFields.URI to uri.toString(),
"contentId" to uri.tryParseId(), EntryFields.CONTENT_ID to uri.tryParseId(),
"path" to path, EntryFields.PATH to path,
) )
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) } cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields[EntryFields.DATE_ADDED_SECS] = cursor.getInt(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) } cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_SECS] = cursor.getInt(it) }
cursor.close() cursor.close()
return newFields return newFields
} }

View file

@ -7,6 +7,7 @@ import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.metadataextractor.Helper import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
@ -43,9 +44,9 @@ open class UnknownContentProvider : ImageProvider() {
} }
val fields: FieldMap = hashMapOf( val fields: FieldMap = hashMapOf(
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT, EntryFields.ORIGIN to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
"uri" to uri.toString(), EntryFields.URI to uri.toString(),
"sourceMimeType" to mimeType, EntryFields.SOURCE_MIME_TYPE to mimeType,
) )
try { try {
// some providers do not provide the mandatory `OpenableColumns` // some providers do not provide the mandatory `OpenableColumns`
@ -53,11 +54,11 @@ open class UnknownContentProvider : ImageProvider() {
// e.g. `content://mms/part/[id]` on Android KitKat // e.g. `content://mms/part/[id]` on Android KitKat
val cursor = context.contentResolver.query(uri, null, null, null, null) val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) { if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) } cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields[EntryFields.TITLE] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) } cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields["path"] = cursor.getString(it) } cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields[EntryFields.PATH] = cursor.getString(it) }
// mime type fallback if it was not provided and not found via `metadata-extractor` // mime type fallback if it was not provided and not found via `metadata-extractor`
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields["sourceMimeType"] = cursor.getString(it) } cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields[EntryFields.SOURCE_MIME_TYPE] = cursor.getString(it) }
cursor.close() cursor.close()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -65,7 +66,7 @@ open class UnknownContentProvider : ImageProvider() {
return return
} }
if (fields["sourceMimeType"] == null) { if (fields[EntryFields.SOURCE_MIME_TYPE] == null) {
callback.onFailure(Exception("Failed to find MIME type for uri=$uri")) callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
return return
} }

View file

@ -1,8 +1,8 @@
package deckers.thibault.aves.utils package deckers.thibault.aves.utils
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
object MimeTypes { object MimeTypes {
const val ANY = "*/*" const val ANY = "*/*"

View file

@ -9,7 +9,6 @@ import android.content.pm.PackageManager
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
@ -30,6 +29,8 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.Locale import java.util.Locale
import java.util.regex.Pattern import java.util.regex.Pattern
import androidx.core.net.toUri
import androidx.core.text.isDigitsOnly
object StorageUtils { object StorageUtils {
private val LOG_TAG = LogUtils.createTag<StorageUtils>() private val LOG_TAG = LogUtils.createTag<StorageUtils>()
@ -81,7 +82,8 @@ object StorageUtils {
return null return null
} }
val trashDir = File(externalFilesDir, "trash") val trashDir = File(externalFilesDir, "trash")
if (!trashDir.exists() && !trashDir.mkdirs()) { trashDir.mkdirs()
if (!trashDir.exists()) {
Log.e(LOG_TAG, "failed to create directories at path=$trashDir") Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
return null return null
} }
@ -228,7 +230,7 @@ object StorageUtils {
// Device has emulated storage; external storage paths should have userId burned into them. // Device has emulated storage; external storage paths should have userId burned into them.
// /storage/emulated/[0,1,2,...]/ // /storage/emulated/[0,1,2,...]/
val path = getPrimaryVolumePath(context) val path = getPrimaryVolumePath(context)
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: "" val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { it.isDigitsOnly() } ?: ""
if (rawUserId.isEmpty()) { if (rawUserId.isEmpty()) {
paths.add(rawEmulatedStorageTarget) paths.add(rawEmulatedStorageTarget)
} else { } else {
@ -499,7 +501,8 @@ object StorageUtils {
parentFile parentFile
} else { } else {
val directory = File(cleanDirPath) val directory = File(cleanDirPath)
if (!directory.exists() && !directory.mkdirs()) { directory.mkdirs()
if (!directory.exists()) {
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath") Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
return null return null
} }
@ -636,7 +639,7 @@ object StorageUtils {
// strip user info, if any // strip user info, if any
// e.g. `content://0@media/...` // e.g. `content://0@media/...`
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", "")) private fun stripMediaUriUserInfo(uri: Uri) = uri.toString().replaceFirst("${uri.userInfo}@", "").toUri()
fun openInputStream(context: Context, uri: Uri): InputStream? { fun openInputStream(context: Context, uri: Uri): InputStream? {
val effectiveUri = getOriginalUri(context, uri) val effectiveUri = getOriginalUri(context, uri)
@ -712,7 +715,8 @@ object StorageUtils {
fun createTempFile(context: Context, extension: String? = null): File { fun createTempFile(context: Context, extension: String? = null): File {
val directory = getTempDirectory(context) val directory = getTempDirectory(context)
if (!directory.exists() && !directory.mkdirs()) { directory.mkdirs()
if (!directory.exists()) {
throw IOException("failed to create directories at path=$directory") throw IOException("failed to create directories at path=$directory")
} }
val tempFile = File.createTempFile("aves", extension, directory) val tempFile = File.createTempFile("aves", extension, directory)

View file

@ -8,4 +8,5 @@
<string name="app_name">Aves</string> <string name="app_name">Aves</string>
<string name="analysis_channel_name">Skönnun myndefnis</string> <string name="analysis_channel_name">Skönnun myndefnis</string>
<string name="search_shortcut_short_label">Leita</string> <string name="search_shortcut_short_label">Leita</string>
<string name="map_shortcut_short_label">Landakort</string>
</resources> </resources>

View file

@ -8,4 +8,5 @@
<string name="analysis_notification_default_title">Scanarea suporturilor</string> <string name="analysis_notification_default_title">Scanarea suporturilor</string>
<string name="analysis_notification_action_stop">Stop</string> <string name="analysis_notification_action_stop">Stop</string>
<string name="search_shortcut_short_label">Căutare</string> <string name="search_shortcut_short_label">Căutare</string>
<string name="map_shortcut_short_label">Hartă</string>
</resources> </resources>

View file

@ -13,8 +13,8 @@ buildscript {
dependencies { dependencies {
if (useCrashlytics) { if (useCrashlytics) {
// GMS & Firebase Crashlytics (used by some flavors only) // GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.4.1' classpath 'com.google.gms:google-services:4.4.2'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9' classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2'
} }
} }
} }

View file

@ -26,6 +26,7 @@ import static androidx.exifinterface.media.ExifInterfaceUtilsFork.startsWith;
import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.ElementType.TYPE_USE;
import static java.nio.ByteOrder.BIG_ENDIAN; import static java.nio.ByteOrder.BIG_ENDIAN;
import static java.nio.ByteOrder.LITTLE_ENDIAN; import static java.nio.ByteOrder.LITTLE_ENDIAN;
import static java.nio.charset.StandardCharsets.UTF_8;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.content.res.AssetManager; import android.content.res.AssetManager;
@ -54,6 +55,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.DataInput; import java.io.DataInput;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException; import java.io.EOFException;
import java.io.File; import java.io.File;
import java.io.FileDescriptor; import java.io.FileDescriptor;
@ -89,8 +91,9 @@ import java.util.regex.Pattern;
import java.util.zip.CRC32; import java.util.zip.CRC32;
/* /*
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-alpha01' on 2024/11/17 * Forked from 'androidx.exifinterface:exifinterface:1.4.0-beta01' on 2025/01/21
* Named differently to let ExifInterface be loaded as subdependency. * Named differently to let ExifInterface be loaded as subdependency.
* cf https://maven.google.com/web/index.html?q=exifinterface#androidx.exifinterface:exifinterface
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media * cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
*/ */
@ -190,6 +193,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #DATA_UNCOMPRESSED * @see #DATA_UNCOMPRESSED
* @see #DATA_JPEG * @see #DATA_JPEG
*/ */
@ -205,6 +209,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #PHOTOMETRIC_INTERPRETATION_RGB * @see #PHOTOMETRIC_INTERPRETATION_RGB
* @see #PHOTOMETRIC_INTERPRETATION_YCBCR * @see #PHOTOMETRIC_INTERPRETATION_YCBCR
*/ */
@ -219,6 +224,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #ORIENTATION_NORMAL}</li> * <li>Default = {@link #ORIENTATION_NORMAL}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #ORIENTATION_UNDEFINED * @see #ORIENTATION_UNDEFINED
* @see #ORIENTATION_NORMAL * @see #ORIENTATION_NORMAL
* @see #ORIENTATION_FLIP_HORIZONTAL * @see #ORIENTATION_FLIP_HORIZONTAL
@ -254,6 +260,7 @@ public class ExifInterfaceFork {
* <li>Count = 1</li> * <li>Count = 1</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #FORMAT_CHUNKY * @see #FORMAT_CHUNKY
* @see #FORMAT_PLANAR * @see #FORMAT_PLANAR
*/ */
@ -294,6 +301,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li> * <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #Y_CB_CR_POSITIONING_CENTERED * @see #Y_CB_CR_POSITIONING_CENTERED
* @see #Y_CB_CR_POSITIONING_CO_SITED * @see #Y_CB_CR_POSITIONING_CO_SITED
*/ */
@ -309,6 +317,7 @@ public class ExifInterfaceFork {
* <li>Default = 72</li> * <li>Default = 72</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #TAG_Y_RESOLUTION * @see #TAG_Y_RESOLUTION
* @see #TAG_RESOLUTION_UNIT * @see #TAG_RESOLUTION_UNIT
*/ */
@ -324,6 +333,7 @@ public class ExifInterfaceFork {
* <li>Default = 72</li> * <li>Default = 72</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #TAG_X_RESOLUTION * @see #TAG_X_RESOLUTION
* @see #TAG_RESOLUTION_UNIT * @see #TAG_RESOLUTION_UNIT
*/ */
@ -340,6 +350,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li> * <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #RESOLUTION_UNIT_INCHES * @see #RESOLUTION_UNIT_INCHES
* @see #RESOLUTION_UNIT_CENTIMETERS * @see #RESOLUTION_UNIT_CENTIMETERS
* @see #TAG_X_RESOLUTION * @see #TAG_X_RESOLUTION
@ -365,6 +376,7 @@ public class ExifInterfaceFork {
* <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1) * <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1)
* / {@link #TAG_ROWS_PER_STRIP})</p> * / {@link #TAG_ROWS_PER_STRIP})</p>
* <p> * <p>
*
* @see #TAG_ROWS_PER_STRIP * @see #TAG_ROWS_PER_STRIP
* @see #TAG_STRIP_BYTE_COUNTS * @see #TAG_STRIP_BYTE_COUNTS
*/ */
@ -381,6 +393,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #TAG_STRIP_OFFSETS * @see #TAG_STRIP_OFFSETS
* @see #TAG_STRIP_BYTE_COUNTS * @see #TAG_STRIP_BYTE_COUNTS
*/ */
@ -656,6 +669,7 @@ public class ExifInterfaceFork {
* <li>Count = 1</li> * <li>Count = 1</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #COLOR_SPACE_S_RGB * @see #COLOR_SPACE_S_RGB
* @see #COLOR_SPACE_UNCALIBRATED * @see #COLOR_SPACE_UNCALIBRATED
*/ */
@ -962,6 +976,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li> * <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #EXPOSURE_PROGRAM_NOT_DEFINED * @see #EXPOSURE_PROGRAM_NOT_DEFINED
* @see #EXPOSURE_PROGRAM_MANUAL * @see #EXPOSURE_PROGRAM_MANUAL
* @see #EXPOSURE_PROGRAM_NORMAL * @see #EXPOSURE_PROGRAM_NORMAL
@ -1031,6 +1046,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SENSITIVITY_TYPE_UNKNOWN * @see #SENSITIVITY_TYPE_UNKNOWN
* @see #SENSITIVITY_TYPE_SOS * @see #SENSITIVITY_TYPE_SOS
* @see #SENSITIVITY_TYPE_REI * @see #SENSITIVITY_TYPE_REI
@ -1197,6 +1213,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #METERING_MODE_UNKNOWN}</li> * <li>Default = {@link #METERING_MODE_UNKNOWN}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #METERING_MODE_UNKNOWN * @see #METERING_MODE_UNKNOWN
* @see #METERING_MODE_AVERAGE * @see #METERING_MODE_AVERAGE
* @see #METERING_MODE_CENTER_WEIGHT_AVERAGE * @see #METERING_MODE_CENTER_WEIGHT_AVERAGE
@ -1217,6 +1234,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li> * <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #LIGHT_SOURCE_UNKNOWN * @see #LIGHT_SOURCE_UNKNOWN
* @see #LIGHT_SOURCE_DAYLIGHT * @see #LIGHT_SOURCE_DAYLIGHT
* @see #LIGHT_SOURCE_FLUORESCENT * @see #LIGHT_SOURCE_FLUORESCENT
@ -1253,6 +1271,7 @@ public class ExifInterfaceFork {
* <li>Count = 1</li> * <li>Count = 1</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #FLAG_FLASH_FIRED * @see #FLAG_FLASH_FIRED
* @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED * @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED
* @see #FLAG_FLASH_RETURN_LIGHT_DETECTED * @see #FLAG_FLASH_RETURN_LIGHT_DETECTED
@ -1365,6 +1384,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li> * <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #TAG_RESOLUTION_UNIT * @see #TAG_RESOLUTION_UNIT
* @see #RESOLUTION_UNIT_INCHES * @see #RESOLUTION_UNIT_INCHES
* @see #RESOLUTION_UNIT_CENTIMETERS * @see #RESOLUTION_UNIT_CENTIMETERS
@ -1407,6 +1427,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SENSOR_TYPE_NOT_DEFINED * @see #SENSOR_TYPE_NOT_DEFINED
* @see #SENSOR_TYPE_ONE_CHIP * @see #SENSOR_TYPE_ONE_CHIP
* @see #SENSOR_TYPE_TWO_CHIP * @see #SENSOR_TYPE_TWO_CHIP
@ -1427,6 +1448,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #FILE_SOURCE_DSC}</li> * <li>Default = {@link #FILE_SOURCE_DSC}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #FILE_SOURCE_OTHER * @see #FILE_SOURCE_OTHER
* @see #FILE_SOURCE_TRANSPARENT_SCANNER * @see #FILE_SOURCE_TRANSPARENT_SCANNER
* @see #FILE_SOURCE_REFLEX_SCANNER * @see #FILE_SOURCE_REFLEX_SCANNER
@ -1444,6 +1466,7 @@ public class ExifInterfaceFork {
* <li>Default = 1</li> * <li>Default = 1</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED * @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED
*/ */
public static final String TAG_SCENE_TYPE = "SceneType"; public static final String TAG_SCENE_TYPE = "SceneType";
@ -1457,6 +1480,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #TAG_SENSING_METHOD * @see #TAG_SENSING_METHOD
* @see #SENSOR_TYPE_ONE_CHIP * @see #SENSOR_TYPE_ONE_CHIP
*/ */
@ -1473,6 +1497,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li> * <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #RENDERED_PROCESS_NORMAL * @see #RENDERED_PROCESS_NORMAL
* @see #RENDERED_PROCESS_CUSTOM * @see #RENDERED_PROCESS_CUSTOM
*/ */
@ -1489,6 +1514,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #EXPOSURE_MODE_AUTO * @see #EXPOSURE_MODE_AUTO
* @see #EXPOSURE_MODE_MANUAL * @see #EXPOSURE_MODE_MANUAL
* @see #EXPOSURE_MODE_AUTO_BRACKET * @see #EXPOSURE_MODE_AUTO_BRACKET
@ -1504,6 +1530,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #WHITEBALANCE_AUTO * @see #WHITEBALANCE_AUTO
* @see #WHITEBALANCE_MANUAL * @see #WHITEBALANCE_MANUAL
*/ */
@ -1553,6 +1580,7 @@ public class ExifInterfaceFork {
* <li>Default = 0</li> * <li>Default = 0</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SCENE_CAPTURE_TYPE_STANDARD * @see #SCENE_CAPTURE_TYPE_STANDARD
* @see #SCENE_CAPTURE_TYPE_LANDSCAPE * @see #SCENE_CAPTURE_TYPE_LANDSCAPE
* @see #SCENE_CAPTURE_TYPE_PORTRAIT * @see #SCENE_CAPTURE_TYPE_PORTRAIT
@ -1569,6 +1597,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GAIN_CONTROL_NONE * @see #GAIN_CONTROL_NONE
* @see #GAIN_CONTROL_LOW_GAIN_UP * @see #GAIN_CONTROL_LOW_GAIN_UP
* @see #GAIN_CONTROL_HIGH_GAIN_UP * @see #GAIN_CONTROL_HIGH_GAIN_UP
@ -1587,6 +1616,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #CONTRAST_NORMAL}</li> * <li>Default = {@link #CONTRAST_NORMAL}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #CONTRAST_NORMAL * @see #CONTRAST_NORMAL
* @see #CONTRAST_SOFT * @see #CONTRAST_SOFT
* @see #CONTRAST_HARD * @see #CONTRAST_HARD
@ -1603,6 +1633,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #SATURATION_NORMAL}</li> * <li>Default = {@link #SATURATION_NORMAL}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SATURATION_NORMAL * @see #SATURATION_NORMAL
* @see #SATURATION_LOW * @see #SATURATION_LOW
* @see #SATURATION_HIGH * @see #SATURATION_HIGH
@ -1619,6 +1650,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #SHARPNESS_NORMAL}</li> * <li>Default = {@link #SHARPNESS_NORMAL}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SHARPNESS_NORMAL * @see #SHARPNESS_NORMAL
* @see #SHARPNESS_SOFT * @see #SHARPNESS_SOFT
* @see #SHARPNESS_HARD * @see #SHARPNESS_HARD
@ -1646,6 +1678,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #SUBJECT_DISTANCE_RANGE_UNKNOWN * @see #SUBJECT_DISTANCE_RANGE_UNKNOWN
* @see #SUBJECT_DISTANCE_RANGE_MACRO * @see #SUBJECT_DISTANCE_RANGE_MACRO
* @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW * @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW
@ -1675,6 +1708,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead. * @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead.
*/ */
@Deprecated @Deprecated
@ -1780,6 +1814,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #LATITUDE_NORTH * @see #LATITUDE_NORTH
* @see #LATITUDE_SOUTH * @see #LATITUDE_SOUTH
*/ */
@ -1809,6 +1844,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #LONGITUDE_EAST * @see #LONGITUDE_EAST
* @see #LONGITUDE_WEST * @see #LONGITUDE_WEST
*/ */
@ -1841,6 +1877,7 @@ public class ExifInterfaceFork {
* <li>Default = 0</li> * <li>Default = 0</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #ALTITUDE_ABOVE_SEA_LEVEL * @see #ALTITUDE_ABOVE_SEA_LEVEL
* @see #ALTITUDE_BELOW_SEA_LEVEL * @see #ALTITUDE_BELOW_SEA_LEVEL
*/ */
@ -1899,6 +1936,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_MEASUREMENT_IN_PROGRESS * @see #GPS_MEASUREMENT_IN_PROGRESS
* @see #GPS_MEASUREMENT_INTERRUPTED * @see #GPS_MEASUREMENT_INTERRUPTED
*/ */
@ -1915,6 +1953,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_MEASUREMENT_2D * @see #GPS_MEASUREMENT_2D
* @see #GPS_MEASUREMENT_3D * @see #GPS_MEASUREMENT_3D
*/ */
@ -1941,6 +1980,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li> * <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_SPEED_KILOMETERS_PER_HOUR * @see #GPS_SPEED_KILOMETERS_PER_HOUR
* @see #GPS_SPEED_MILES_PER_HOUR * @see #GPS_SPEED_MILES_PER_HOUR
* @see #GPS_SPEED_KNOTS * @see #GPS_SPEED_KNOTS
@ -1968,6 +2008,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li> * <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_DIRECTION_TRUE * @see #GPS_DIRECTION_TRUE
* @see #GPS_DIRECTION_MAGNETIC * @see #GPS_DIRECTION_MAGNETIC
*/ */
@ -1994,6 +2035,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li> * <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_DIRECTION_TRUE * @see #GPS_DIRECTION_TRUE
* @see #GPS_DIRECTION_MAGNETIC * @see #GPS_DIRECTION_MAGNETIC
*/ */
@ -2032,6 +2074,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #LATITUDE_NORTH * @see #LATITUDE_NORTH
* @see #LATITUDE_SOUTH * @see #LATITUDE_SOUTH
*/ */
@ -2061,6 +2104,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #LONGITUDE_EAST * @see #LONGITUDE_EAST
* @see #LONGITUDE_WEST * @see #LONGITUDE_WEST
*/ */
@ -2090,6 +2134,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li> * <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_DIRECTION_TRUE * @see #GPS_DIRECTION_TRUE
* @see #GPS_DIRECTION_MAGNETIC * @see #GPS_DIRECTION_MAGNETIC
*/ */
@ -2116,6 +2161,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li> * <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_DISTANCE_KILOMETERS * @see #GPS_DISTANCE_KILOMETERS
* @see #GPS_DISTANCE_MILES * @see #GPS_DISTANCE_MILES
* @see #GPS_DISTANCE_NAUTICAL_MILES * @see #GPS_DISTANCE_NAUTICAL_MILES
@ -2177,6 +2223,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li> * <li>Default = None</li>
* </ul> * </ul>
* <p> * <p>
*
* @see #GPS_MEASUREMENT_NO_DIFFERENTIAL * @see #GPS_MEASUREMENT_NO_DIFFERENTIAL
* @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED * @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED
*/ */
@ -3132,11 +3179,18 @@ public class ExifInterfaceFork {
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0", // See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
// 3.7. eXIf Exchangeable Image File (Exif) Profile // 3.7. eXIf Exchangeable Image File (Exif) Profile
private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f'; private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
// See "XMP Specification Part 3: Storage in Files" section 1.1.5
private static final int PNG_CHUNK_TYPE_ITXT = 'i' << 24 | 'T' << 16 | 'X' << 8 | 't';
private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R'; private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D'; private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4; private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
/**
* The keyword and 5 null bytes defined by XMP spec part 3 table 9 (section 1.1.5).
*/
@VisibleForTesting
static final byte[] PNG_ITXT_XMP_KEYWORD = "XML:com.adobe.xmp\0\0\0\0\0".getBytes(UTF_8);
// See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header" // See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'}; private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'};
private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'}; private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'};
@ -4069,20 +4123,33 @@ public class ExifInterfaceFork {
// Used to indicate offset from the start of the original input stream to EXIF data // Used to indicate offset from the start of the original input stream to EXIF data
private int mOffsetToExifData; private int mOffsetToExifData;
private int mOrfMakerNoteOffset; private int mOrfMakerNoteOffset;
/**
* The position of the thumbnail within the Exif data (from {@link #mOffsetToExifData}).
*/
private int mOrfThumbnailOffset; private int mOrfThumbnailOffset;
private int mOrfThumbnailLength; private int mOrfThumbnailLength;
private boolean mModified; private boolean mModified;
/** /**
* XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate * XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
* section of the file (e.g. a separate APP1 segment in JPEG). XMP read from within the * section of the file (e.g. a separate APP1 segment in JPEG, or an iTXt chunk in PNG). XMP read
* TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a separate section is * from within the TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a
* here. If both are present, the disambiguation rules vary per file format, see * separate section is here. If both are present, the disambiguation rules vary per file format,
* {@link #getXmpHandlingForImageType(int)}. * see {@link #getXmpHandlingForImageType(int)}.
*/ */
@Nullable @Nullable
private ExifAttribute mXmpFromSeparateMarker; private ExifAttribute mXmpFromSeparateMarker;
/**
* True if the file on disk contains XMP in a separate section.
*
* <p>This means the file the instance was loaded with, or the file created by the last call to
* {@link #saveAttributes()}.
*/
private boolean mFileOnDiskContainsSeparateXmpMarker;
// Pattern to check non zero timestamp // Pattern to check non zero timestamp
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*"); private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
// Pattern to check gps timestamp // Pattern to check gps timestamp
@ -4300,6 +4367,7 @@ public class ExifInterfaceFork {
return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT; return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
case IMAGE_TYPE_AVIF: case IMAGE_TYPE_AVIF:
case IMAGE_TYPE_HEIC: case IMAGE_TYPE_HEIC:
case IMAGE_TYPE_PNG:
// RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations // RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
// so we can implement the spec to store XMP in a separate APP1 segment. // so we can implement the spec to store XMP in a separate APP1 segment.
case IMAGE_TYPE_RAF: case IMAGE_TYPE_RAF:
@ -4309,10 +4377,8 @@ public class ExifInterfaceFork {
case IMAGE_TYPE_PEF: case IMAGE_TYPE_PEF:
case IMAGE_TYPE_RW2: case IMAGE_TYPE_RW2:
case IMAGE_TYPE_UNKNOWN: case IMAGE_TYPE_UNKNOWN:
// PNG and WebP support a separate XMP chunk (so should be // WebP supports a separate XMP chunk (so should be XMP_HANDLING_PREFER_SEPARATE), but
// XMP_HANDLING_PREFER_SEPARATE), but ExifInterface doesn't currently read or write // ExifInterface doesn't currently read or write it.
// them.
case IMAGE_TYPE_PNG:
case IMAGE_TYPE_WEBP: case IMAGE_TYPE_WEBP:
default: default:
return XMP_HANDLING_TIFF_700_ONLY; return XMP_HANDLING_TIFF_700_ONLY;
@ -5160,14 +5226,18 @@ public class ExifInterfaceFork {
} }
/** /**
* Returns the offset and length of the requested tag inside the image file, * Returns the offset and length of the requested tag inside the image file, or {@code null} if
* or {@code null} if the tag is not contained. * the tag is not contained.
* *
* @return two-element array, the offset in the first value, and length in * <p>If the attribute has been modified with {@link #setAttribute(String, String)} but not yet
* the second, or {@code null} if no tag was found. * written to disk with {@link #saveAttributes()}, the returned range will have the correct
* @throws IllegalStateException if {@link #saveAttributes()} has been * length for the modified value, but an offset of {@code -1} to indicate its position in the
* called since the underlying file was initially parsed, since * file isn't known.
* that means offsets may have changed. *
* @return two-element array, the offset in the first value, and length in the second, or {@code
* null} if no tag was found.
* @throws IllegalStateException if {@link #saveAttributes()} has been called since the
* underlying file was initially parsed, since that means offsets may have changed.
*/ */
public long @Nullable [] getAttributeRange(@NonNull String tag) { public long @Nullable [] getAttributeRange(@NonNull String tag) {
if (tag == null) { if (tag == null) {
@ -5841,6 +5911,7 @@ public class ExifInterfaceFork {
IDENTIFIER_XMP_APP1.length, bytes.length); IDENTIFIER_XMP_APP1.length, bytes.length);
mXmpFromSeparateMarker = mXmpFromSeparateMarker =
new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value); new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
mFileOnDiskContainsSeparateXmpMarker = true;
} }
break; break;
} }
@ -6165,6 +6236,7 @@ public class ExifInterfaceFork {
in.readFully(xmpBytes); in.readFully(xmpBytes);
mXmpFromSeparateMarker = mXmpFromSeparateMarker =
new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes); new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
mFileOnDiskContainsSeparateXmpMarker = true;
} }
if (DEBUG) { if (DEBUG) {
@ -6352,10 +6424,12 @@ public class ExifInterfaceFork {
// See PNG (Portable Network Graphics) Specification, Version 1.2, // See PNG (Portable Network Graphics) Specification, Version 1.2,
// 3.2. Chunk layout // 3.2. Chunk layout
try { try {
while (true) { boolean foundExif = false;
boolean foundXmpItxt = false;
while (!foundExif || !foundXmpItxt) {
int length = in.readInt(); int length = in.readInt();
int type = in.readInt(); int type = in.readInt();
int startOfNextChunk = in.position() + length + PNG_CHUNK_CRC_BYTE_LENGTH;
// The first chunk must be the IHDR chunk // The first chunk must be the IHDR chunk
if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) { if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) {
@ -6367,7 +6441,7 @@ public class ExifInterfaceFork {
if (type == PNG_CHUNK_TYPE_IEND) { if (type == PNG_CHUNK_TYPE_IEND) {
// IEND marks the end of the image. // IEND marks the end of the image.
break; break;
} else if (type == PNG_CHUNK_TYPE_EXIF) { } else if (type == PNG_CHUNK_TYPE_EXIF && !foundExif) {
// Save offset to EXIF data for handling thumbnail and attribute offsets. // Save offset to EXIF data for handling thumbnail and attribute offsets.
mOffsetToExifData = in.position() - startPosition; mOffsetToExifData = in.position() - startPosition;
@ -6388,20 +6462,40 @@ public class ExifInterfaceFork {
updateCrcWithInt(crc, type); updateCrcWithInt(crc, type);
crc.update(data); crc.update(data);
if ((int) crc.getValue() != dataCrcValue) { if ((int) crc.getValue() != dataCrcValue) {
throw new IOException("Encountered invalid CRC value for PNG-EXIF chunk." throw new IOException(
+ "\n recorded CRC value: " + dataCrcValue + ", calculated CRC " "Encountered invalid CRC value for PNG-EXIF chunk."
+ "value: " + crc.getValue()); + "\n recorded CRC value: "
+ dataCrcValue
+ ", calculated CRC "
+ "value: "
+ crc.getValue());
} }
readExifSegment(data, IFD_TYPE_PRIMARY); readExifSegment(data, IFD_TYPE_PRIMARY);
validateImages(); validateImages();
setThumbnailData(new ByteOrderedDataInputStream(data)); setThumbnailData(new ByteOrderedDataInputStream(data));
break; foundExif = true;
} else { } else if (type == PNG_CHUNK_TYPE_ITXT
&& !foundXmpItxt
&& length >= PNG_ITXT_XMP_KEYWORD.length) {
// Read the 17 byte keyword and 5 expected null bytes.
byte[] keyword = new byte[PNG_ITXT_XMP_KEYWORD.length];
in.readFully(keyword);
if (Arrays.equals(keyword, PNG_ITXT_XMP_KEYWORD)) {
int xmpDataOffset = in.position() - startPosition;
int xmpLength = length - keyword.length;
byte[] xmpData = new byte[xmpLength];
in.readFully(xmpData);
mXmpFromSeparateMarker =
new ExifAttribute(
IFD_FORMAT_BYTE, xmpLength, xmpDataOffset, xmpData);
foundXmpItxt = true;
}
}
// Skip to next chunk // Skip to next chunk
in.skipFully(length + PNG_CHUNK_CRC_BYTE_LENGTH); in.skipFully(startOfNextChunk - in.position());
}
} }
mFileOnDiskContainsSeparateXmpMarker = foundXmpItxt;
} catch (EOFException e) { } catch (EOFException e) {
// Should not reach here. Will only reach here if the file is corrupted or // Should not reach here. Will only reach here if the file is corrupted or
// does not follow the PNG specifications // does not follow the PNG specifications
@ -6464,9 +6558,8 @@ public class ExifInterfaceFork {
// Exif data in WebP images (e.g. // Exif data in WebP images (e.g.
// https://github.com/ImageMagick/ImageMagick/issues/3140) // https://github.com/ImageMagick/ImageMagick/issues/3140)
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) { if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
int adjustedChunkSize = chunkSize - IDENTIFIER_EXIF_APP1.length;
payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length, payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length,
adjustedChunkSize); payload.length);
} }
// Save offset to EXIF data for handling thumbnail and attribute offsets. // Save offset to EXIF data for handling thumbnail and attribute offsets.
@ -6522,7 +6615,7 @@ public class ExifInterfaceFork {
// Write EXIF APP1 segment // Write EXIF APP1 segment
dataOutputStream.writeByte(MARKER); dataOutputStream.writeByte(MARKER);
dataOutputStream.writeByte(MARKER_APP1); dataOutputStream.writeByte(MARKER_APP1);
writeExifSegment(dataOutputStream); mOffsetToExifData = writeExifSegment(dataOutputStream);
if (mXmpFromSeparateMarker != null) { if (mXmpFromSeparateMarker != null) {
// Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to // Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
@ -6533,6 +6626,7 @@ public class ExifInterfaceFork {
dataOutputStream.writeUnsignedShort(length); dataOutputStream.writeUnsignedShort(length);
dataOutputStream.write(IDENTIFIER_XMP_APP1); dataOutputStream.write(IDENTIFIER_XMP_APP1);
dataOutputStream.write(mXmpFromSeparateMarker.bytes); dataOutputStream.write(mXmpFromSeparateMarker.bytes);
mFileOnDiskContainsSeparateXmpMarker = true;
} }
byte[] bytes = new byte[4096]; byte[] bytes = new byte[4096];
@ -6627,60 +6721,76 @@ public class ExifInterfaceFork {
// Copy PNG signature bytes // Copy PNG signature bytes
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length); copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
// EXIF chunk can appear anywhere between the first (IHDR) and last (IEND) chunks, except boolean needToWriteExif = true;
// between IDAT chunks. boolean needToWriteXmp = mXmpFromSeparateMarker != null;
// Adhering to these rules, while (needToWriteExif || needToWriteXmp) {
// 1) if EXIF chunk did not exist in the original file, it will be stored right after the int chunkLength = dataInputStream.readInt();
// first chunk, int chunkType = dataInputStream.readInt();
// 2) if EXIF chunk existed in the original file, it will be stored in the same location. if (chunkType == PNG_CHUNK_TYPE_IHDR) {
dataOutputStream.writeInt(chunkLength);
dataOutputStream.writeInt(chunkType);
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
if (mOffsetToExifData == 0) { if (mOffsetToExifData == 0) {
// Copy IHDR chunk bytes // There was no Exif segment in the original file, so we put it directly
int ihdrChunkLength = dataInputStream.readInt(); // after the IHDR chunk.
dataOutputStream.writeInt(ihdrChunkLength); writePngExifChunk(dataOutputStream);
copy(dataInputStream, dataOutputStream, PNG_CHUNK_TYPE_BYTE_LENGTH needToWriteExif = false;
+ ihdrChunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
} else {
// Copy up until the point where EXIF chunk length information is stored.
int copyLength = mOffsetToExifData - PNG_SIGNATURE.length
- 4 /* PNG EXIF chunk length bytes */
- PNG_CHUNK_TYPE_BYTE_LENGTH;
copy(dataInputStream, dataOutputStream, copyLength);
// Skip to the start of the chunk after the EXIF chunk
int exifChunkLength = dataInputStream.readInt();
dataInputStream.skipFully(PNG_CHUNK_TYPE_BYTE_LENGTH + exifChunkLength
+ PNG_CHUNK_CRC_BYTE_LENGTH);
} }
if (mXmpFromSeparateMarker != null && !mFileOnDiskContainsSeparateXmpMarker) {
// Write EXIF data writePngXmpItxtChunk(dataOutputStream);
ByteArrayOutputStream exifByteArrayOutputStream = null; needToWriteXmp = false;
try { }
// A byte array is needed to calculate the CRC value of this chunk which requires continue;
// the chunk type bytes and the chunk data bytes. } else if (chunkType == PNG_CHUNK_TYPE_EXIF && needToWriteExif) {
exifByteArrayOutputStream = new ByteArrayOutputStream(); writePngExifChunk(dataOutputStream);
ByteOrderedDataOutputStream exifDataOutputStream = dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN); needToWriteExif = false;
continue;
// Store Exif data in separate byte array } else if (chunkType == PNG_CHUNK_TYPE_ITXT && needToWriteXmp) {
writeExifSegment(exifDataOutputStream); writePngXmpItxtChunk(dataOutputStream);
byte[] exifBytes = dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
((ByteArrayOutputStream) exifDataOutputStream.mOutputStream).toByteArray(); needToWriteXmp = false;
continue;
// Write EXIF chunk data }
dataOutputStream.write(exifBytes); dataOutputStream.writeInt(chunkLength);
dataOutputStream.writeInt(chunkType);
// Write EXIF chunk CRC copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
CRC32 crc = new CRC32();
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
dataOutputStream.writeInt((int) crc.getValue());
} finally {
closeQuietly(exifByteArrayOutputStream);
} }
// Copy the rest of the file // Copy the rest of the file
copy(dataInputStream, dataOutputStream); copy(dataInputStream, dataOutputStream);
} }
private void writePngExifChunk(ByteOrderedDataOutputStream dataOutputStream)
throws IOException {
// Write the eXIF chunk out to an intermediate byte array so we can calculate the CRC value.
ByteArrayOutputStream exifByteArrayOutputStream = new ByteArrayOutputStream();
// Write eXIF chunk data (including chunk type & length).
int exifOffset =
writeExifSegment(
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN));
mOffsetToExifData = dataOutputStream.mOutputStream.size() + exifOffset;
byte[] exifBytes = exifByteArrayOutputStream.toByteArray();
dataOutputStream.write(exifBytes);
CRC32 crc = new CRC32();
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
dataOutputStream.writeInt((int) crc.getValue());
}
private void writePngXmpItxtChunk(ByteOrderedDataOutputStream dataOutputStream)
throws IOException {
dataOutputStream.writeInt(mXmpFromSeparateMarker.bytes.length + 22);
CRC32 crc = new CRC32();
dataOutputStream.writeInt(PNG_CHUNK_TYPE_ITXT);
updateCrcWithInt(crc, PNG_CHUNK_TYPE_ITXT);
dataOutputStream.write(PNG_ITXT_XMP_KEYWORD);
crc.update(PNG_ITXT_XMP_KEYWORD);
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
crc.update(mXmpFromSeparateMarker.bytes);
dataOutputStream.writeInt((int) crc.getValue());
mFileOnDiskContainsSeparateXmpMarker = true;
}
// A WebP file has a header and a series of chunks. // A WebP file has a header and a series of chunks.
// The header is composed of: // The header is composed of:
// "RIFF" + File Size + "WEBP" // "RIFF" + File Size + "WEBP"
@ -6726,11 +6836,12 @@ public class ExifInterfaceFork {
// WebP signature // WebP signature
copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length); copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
// File length will be written after all the chunks have been written int riffLength = totalInputStream.readInt();
totalInputStream.skipFully(WEBP_FILE_SIZE_BYTE_LENGTH + WEBP_SIGNATURE_2.length); totalInputStream.skipFully(WEBP_SIGNATURE_2.length);
// Create a separate byte array to calculate file length // Create a separate byte array to calculate file length
ByteArrayOutputStream nonHeaderByteArrayOutputStream = null; ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
int exifOffset = -1;
try { try {
nonHeaderByteArrayOutputStream = new ByteArrayOutputStream(); nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
ByteOrderedDataOutputStream nonHeaderOutputStream = ByteOrderedDataOutputStream nonHeaderOutputStream =
@ -6756,7 +6867,7 @@ public class ExifInterfaceFork {
totalInputStream.skipFully(exifChunkLength); totalInputStream.skipFully(exifChunkLength);
// Write new EXIF chunk to output stream // Write new EXIF chunk to output stream
writeExifSegment(nonHeaderOutputStream); exifOffset = writeExifSegment(nonHeaderOutputStream);
} else { } else {
// EXIF chunk does not exist in the original file // EXIF chunk does not exist in the original file
byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH]; byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
@ -6801,7 +6912,7 @@ public class ExifInterfaceFork {
animationFinished = true; animationFinished = true;
} }
if (animationFinished) { if (animationFinished) {
writeExifSegment(nonHeaderOutputStream); exifOffset = writeExifSegment(nonHeaderOutputStream);
break; break;
} }
copyWebPChunk(totalInputStream, nonHeaderOutputStream, type); copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
@ -6810,7 +6921,7 @@ public class ExifInterfaceFork {
// Skip until we find the VP8 or VP8L chunk // Skip until we find the VP8 or VP8L chunk
copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream, copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L); WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
writeExifSegment(nonHeaderOutputStream); exifOffset = writeExifSegment(nonHeaderOutputStream);
} }
} else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8) } else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
|| Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) { || Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
@ -6897,18 +7008,24 @@ public class ExifInterfaceFork {
copy(totalInputStream, nonHeaderOutputStream, bytesToRead); copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
// Write EXIF chunk // Write EXIF chunk
writeExifSegment(nonHeaderOutputStream); exifOffset = writeExifSegment(nonHeaderOutputStream);
} }
} }
// Copy the rest of the file // Copy the rest of the RIFF part of the file
copy(totalInputStream, nonHeaderOutputStream); int remainingRiffBytes = riffLength + 8 - totalInputStream.position();
copy(totalInputStream, nonHeaderOutputStream, remainingRiffBytes);
// Write file length + second signature // Write file length + second signature
totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size() totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
+ WEBP_SIGNATURE_2.length); + WEBP_SIGNATURE_2.length);
totalOutputStream.write(WEBP_SIGNATURE_2); totalOutputStream.write(WEBP_SIGNATURE_2);
if (exifOffset != -1) {
mOffsetToExifData = totalOutputStream.mOutputStream.size() + exifOffset;
}
nonHeaderByteArrayOutputStream.writeTo(totalOutputStream); nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
// Copy any non-RIFF trailing data
copy(totalInputStream, totalOutputStream);
} catch (Exception e) { } catch (Exception e) {
throw new IOException("Failed to save WebP file", e); throw new IOException("Failed to save WebP file", e);
} finally { } finally {
@ -7624,7 +7741,12 @@ public class ExifInterfaceFork {
} }
} }
// Writes an Exif segment into the given output stream. /**
* Writes an Exif segment into the given output stream.
*
* @return The offset of the start of the Exif data (the byte-order marker) written into {@code
* dataOutputStream}.
*/
private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException { private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException {
// The following variables are for calculating each IFD tag group size in bytes. // The following variables are for calculating each IFD tag group size in bytes.
int[] ifdOffsets = new int[EXIF_TAGS.length]; int[] ifdOffsets = new int[EXIF_TAGS.length];
@ -7772,6 +7894,8 @@ public class ExifInterfaceFork {
break; break;
} }
int offsetToExifData = dataOutputStream.mOutputStream.size();
// Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1. // Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II); dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
dataOutputStream.setByteOrder(mExifByteOrder); dataOutputStream.setByteOrder(mExifByteOrder);
@ -7844,7 +7968,7 @@ public class ExifInterfaceFork {
// Reset the byte order to big endian in order to write remaining parts of the JPEG file. // Reset the byte order to big endian in order to write remaining parts of the JPEG file.
dataOutputStream.setByteOrder(BIG_ENDIAN); dataOutputStream.setByteOrder(BIG_ENDIAN);
return totalSize; return offsetToExifData;
} }
/** /**
@ -8240,12 +8364,12 @@ public class ExifInterfaceFork {
// An output stream to write EXIF data area, which can be written in either little or big endian // An output stream to write EXIF data area, which can be written in either little or big endian
// order. // order.
private static class ByteOrderedDataOutputStream extends FilterOutputStream { private static class ByteOrderedDataOutputStream extends FilterOutputStream {
final OutputStream mOutputStream; final DataOutputStream mOutputStream;
private ByteOrder mByteOrder; private ByteOrder mByteOrder;
public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) { public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
super(out); super(out);
mOutputStream = out; mOutputStream = new DataOutputStream(out);
mByteOrder = byteOrder; mByteOrder = byteOrder;
} }

View file

@ -13,7 +13,6 @@ android.enableJetifier=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false android.nonTransitiveRClass=false
android.nonFinalResIds=false android.nonFinalResIds=false

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip

View file

@ -8,9 +8,9 @@ pluginManagement {
} }
settings.ext.flutterSdkPath = flutterSdkPath() settings.ext.flutterSdkPath = flutterSdkPath()
settings.ext.kotlin_version = '1.9.24' settings.ext.kotlin_version = '2.1.10'
settings.ext.ksp_version = "$kotlin_version-1.0.20" settings.ext.ksp_version = "$kotlin_version-1.0.29"
settings.ext.agp_version = '8.7.0' settings.ext.agp_version = '8.8.0'
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")

View file

@ -0,0 +1,3 @@
In v1.12.3:
- edit locations via GPX tracks
Full changelog available on GitHub

View file

@ -0,0 +1,3 @@
In v1.12.3:
- edit locations via GPX tracks
Full changelog available on GitHub

View file

@ -2,4 +2,4 @@
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc. <b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
<i>Aves</i> integrates with Android (including Android TV) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>. <i>Aves</i> integroituu Androidiin (mukaan lukien Android TV) ja sisältää ominaisuuksia, kuten <b>vempaimet</b>, <b>sovellusten pikakuvakkeet</b>, <b>näytönsäästäjä</b> ja <b>laajamittaisen haun</b> käsittely. Se toimii myös <b>median katselu- ja poimijana</b>.

View file

@ -7,6 +7,7 @@ enum AppMode {
pickFilteredMediaInternal, pickFilteredMediaInternal,
pickUnfilteredMediaInternal, pickUnfilteredMediaInternal,
pickFilterInternal, pickFilterInternal,
previewMap,
screenSaver, screenSaver,
setWallpaper, setWallpaper,
slideshow, slideshow,

View file

@ -1564,5 +1564,13 @@
"newDynamicAlbumDialogTitle": "ألبوم ديناميكي جديد", "newDynamicAlbumDialogTitle": "ألبوم ديناميكي جديد",
"@newDynamicAlbumDialogTitle": {}, "@newDynamicAlbumDialogTitle": {},
"chipActionDecompose": "فصل", "chipActionDecompose": "فصل",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "استيراد GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "التحول الزمني",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "الكل",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1602,5 +1602,13 @@
"tagEditorDiscardDialogMessage": "Искате ли да отхвърлите промените?", "tagEditorDiscardDialogMessage": "Искате ли да отхвърлите промените?",
"@tagEditorDiscardDialogMessage": {}, "@tagEditorDiscardDialogMessage": {},
"filePickerUseThisFolder": "Използвай тази папка", "filePickerUseThisFolder": "Използвай тази папка",
"@filePickerUseThisFolder": {} "@filePickerUseThisFolder": {},
"chipActionDecompose": "Раздели",
"@chipActionDecompose": {},
"coordinateFormatDdm": "Градуси, десетични минути",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Импорт GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Изместване на времето",
"@editEntryLocationDialogTimeShift": {}
} }

View file

@ -508,14 +508,17 @@
"editEntryLocationDialogTitle": "Location", "editEntryLocationDialogTitle": "Location",
"editEntryLocationDialogSetCustom": "Set custom location", "editEntryLocationDialogSetCustom": "Set custom location",
"editEntryLocationDialogChooseOnMap": "Choose on map", "editEntryLocationDialogChooseOnMap": "Choose on map",
"editEntryLocationDialogImportGpx": "Import GPX",
"editEntryLocationDialogLatitude": "Latitude", "editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude", "editEntryLocationDialogLongitude": "Longitude",
"editEntryLocationDialogTimeShift": "Time shift",
"locationPickerUseThisLocationButton": "Use this location", "locationPickerUseThisLocationButton": "Use this location",
"editEntryRatingDialogTitle": "Rating", "editEntryRatingDialogTitle": "Rating",
"removeEntryMetadataDialogTitle": "Metadata Removal", "removeEntryMetadataDialogTitle": "Metadata Removal",
"removeEntryMetadataDialogAll": "All",
"removeEntryMetadataDialogMore": "More", "removeEntryMetadataDialogMore": "More",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?", "removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",

View file

@ -111,7 +111,7 @@
"@chipActionUnpin": {}, "@chipActionUnpin": {},
"chipActionRename": "Muuda nime", "chipActionRename": "Muuda nime",
"@chipActionRename": {}, "@chipActionRename": {},
"chipActionShowCountryStates": "Näita riike", "chipActionShowCountryStates": "Näita osariike",
"@chipActionShowCountryStates": {}, "@chipActionShowCountryStates": {},
"chipActionCreateAlbum": "Loo album", "chipActionCreateAlbum": "Loo album",
"@chipActionCreateAlbum": {}, "@chipActionCreateAlbum": {},
@ -326,7 +326,7 @@
"@filterTypeMotionPhotoLabel": {}, "@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panoraam", "filterTypePanoramaLabel": "Panoraam",
"@filterTypePanoramaLabel": {}, "@filterTypePanoramaLabel": {},
"filterTypeRawLabel": "Töötlemata raw-vormingus foto", "filterTypeRawLabel": "Raw-vorming",
"@filterTypeRawLabel": {}, "@filterTypeRawLabel": {},
"filterTypeSphericalVideoLabel": "360° video", "filterTypeSphericalVideoLabel": "360° video",
"@filterTypeSphericalVideoLabel": {}, "@filterTypeSphericalVideoLabel": {},
@ -963,7 +963,7 @@
"@drawerCollectionMotionPhotos": {}, "@drawerCollectionMotionPhotos": {},
"drawerCollectionPanoramas": "Panoraamfotod", "drawerCollectionPanoramas": "Panoraamfotod",
"@drawerCollectionPanoramas": {}, "@drawerCollectionPanoramas": {},
"drawerCollectionRaws": "Töötlemata fotod", "drawerCollectionRaws": "Raw-vormingus fotod",
"@drawerCollectionRaws": {}, "@drawerCollectionRaws": {},
"drawerCollectionSphericalVideos": "360° videod", "drawerCollectionSphericalVideos": "360° videod",
"@drawerCollectionSphericalVideos": {}, "@drawerCollectionSphericalVideos": {},
@ -1211,7 +1211,7 @@
"@settingsThumbnailShowMotionPhotoIcon": {}, "@settingsThumbnailShowMotionPhotoIcon": {},
"settingsThumbnailShowRating": "Näita hinnangute ikooni", "settingsThumbnailShowRating": "Näita hinnangute ikooni",
"@settingsThumbnailShowRating": {}, "@settingsThumbnailShowRating": {},
"settingsThumbnailShowRawIcon": "Näita töötlemata fotode ikooni", "settingsThumbnailShowRawIcon": "Näita Raw-vormingu ikooni",
"@settingsThumbnailShowRawIcon": {}, "@settingsThumbnailShowRawIcon": {},
"settingsThumbnailShowVideoDuration": "Näita videote kestust", "settingsThumbnailShowVideoDuration": "Näita videote kestust",
"@settingsThumbnailShowVideoDuration": {}, "@settingsThumbnailShowVideoDuration": {},
@ -1604,5 +1604,13 @@
"viewerInfoViewXmlLinkText": "Vaata XMLi", "viewerInfoViewXmlLinkText": "Vaata XMLi",
"@viewerInfoViewXmlLinkText": {}, "@viewerInfoViewXmlLinkText": {},
"chipActionDecompose": "Poolita", "chipActionDecompose": "Poolita",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "KKM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogTimeShift": "Ajanihe",
"@editEntryLocationDialogTimeShift": {},
"editEntryLocationDialogImportGpx": "Impordi GPX-fail",
"@editEntryLocationDialogImportGpx": {},
"removeEntryMetadataDialogAll": "Kõik",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -424,5 +424,7 @@
"storageVolumeDescriptionFallbackPrimary": "Sisäinen tallennustila", "storageVolumeDescriptionFallbackPrimary": "Sisäinen tallennustila",
"@storageVolumeDescriptionFallbackPrimary": {}, "@storageVolumeDescriptionFallbackPrimary": {},
"storageVolumeDescriptionFallbackNonPrimary": "SD-kortti", "storageVolumeDescriptionFallbackNonPrimary": "SD-kortti",
"@storageVolumeDescriptionFallbackNonPrimary": {} "@storageVolumeDescriptionFallbackNonPrimary": {},
"aboutCreditsSectionTitle": "Kiitettävää",
"@aboutCreditsSectionTitle": {}
} }

View file

@ -1406,5 +1406,13 @@
"collectionActionAddDynamicAlbum": "Ajouter un album dynamique", "collectionActionAddDynamicAlbum": "Ajouter un album dynamique",
"@collectionActionAddDynamicAlbum": {}, "@collectionActionAddDynamicAlbum": {},
"chipActionDecompose": "Scinder", "chipActionDecompose": "Scinder",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogTimeShift": "Décalage temporel",
"@editEntryLocationDialogTimeShift": {},
"editEntryLocationDialogImportGpx": "Importer un fichier GPX",
"@editEntryLocationDialogImportGpx": {},
"removeEntryMetadataDialogAll": "Tout",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1562,5 +1562,15 @@
"newDynamicAlbumDialogTitle": "Új Dinamikus Album", "newDynamicAlbumDialogTitle": "Új Dinamikus Album",
"@newDynamicAlbumDialogTitle": {}, "@newDynamicAlbumDialogTitle": {},
"appExportDynamicAlbums": "Dinamikus albumok", "appExportDynamicAlbums": "Dinamikus albumok",
"@appExportDynamicAlbums": {} "@appExportDynamicAlbums": {},
"chipActionDecompose": "Felosztás",
"@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "GPX importálás",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Időeltolódás",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "Összes",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1406,5 +1406,11 @@
"newDynamicAlbumDialogTitle": "Album Dinamis Baru", "newDynamicAlbumDialogTitle": "Album Dinamis Baru",
"@newDynamicAlbumDialogTitle": {}, "@newDynamicAlbumDialogTitle": {},
"chipActionDecompose": "Pisah", "chipActionDecompose": "Pisah",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Impor GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Pergeseran waktu",
"@editEntryLocationDialogTimeShift": {}
} }

View file

@ -1420,7 +1420,7 @@
"@authenticateToUnlockVault": {}, "@authenticateToUnlockVault": {},
"chipActionConfigureVault": "Stilla öryggisgeymslu", "chipActionConfigureVault": "Stilla öryggisgeymslu",
"@chipActionConfigureVault": {}, "@chipActionConfigureVault": {},
"newVaultWarningDialogMessage": "Atriði í öryggisgeymslum eru einungis aðgengileg í þessu forriti og eingum öðrum.\n\nEf þú fjarlægir þetta forrit, eða hreinsar gögn forritsins, muntu tapa öllum þessum atriðum.", "newVaultWarningDialogMessage": "Atriði í öryggisgeymslum eru einungis aðgengileg í þessu forriti og engum öðrum.\n\nEf þú fjarlægir þetta forrit, eða hreinsar gögn forritsins, muntu tapa öllum þessum atriðum.",
"@newVaultWarningDialogMessage": {}, "@newVaultWarningDialogMessage": {},
"keepScreenOnViewerOnly": "Aðeins síða skoðara", "keepScreenOnViewerOnly": "Aðeins síða skoðara",
"@keepScreenOnViewerOnly": {}, "@keepScreenOnViewerOnly": {},
@ -1520,5 +1520,55 @@
"chipActionShowCollection": "Sýna í safni", "chipActionShowCollection": "Sýna í safni",
"@chipActionShowCollection": {}, "@chipActionShowCollection": {},
"mapAttributionOsmData": "Kortagögn frá © [OpenStreetMap](https://www.openstreetmap.org/copyright) þátttakendum", "mapAttributionOsmData": "Kortagögn frá © [OpenStreetMap](https://www.openstreetmap.org/copyright) þátttakendum",
"@mapAttributionOsmData": {} "@mapAttributionOsmData": {},
"explorerActionSelectStorageVolume": "Veldu geymslu",
"@explorerActionSelectStorageVolume": {},
"chipActionDecompose": "Skipta upp",
"@chipActionDecompose": {},
"newDynamicAlbumDialogTitle": "Nýtt breytilegt albúm",
"@newDynamicAlbumDialogTitle": {},
"collectionActionAddDynamicAlbum": "Bæta við breytilegu albúmi",
"@collectionActionAddDynamicAlbum": {},
"sortOrderShortestFirst": "Stysta fyrst",
"@sortOrderShortestFirst": {},
"mapStyleOpenTopoMap": "OpenTopoMap",
"@mapStyleOpenTopoMap": {},
"chipActionRemove": "Fjarlægja",
"@chipActionRemove": {},
"albumTierDynamic": "Breytilegt",
"@albumTierDynamic": {},
"newAlbumDialogAlbumAlreadyExistsHelper": "Albúm er þegar til staðar",
"@newAlbumDialogAlbumAlreadyExistsHelper": {},
"dynamicAlbumAlreadyExists": "Breytilegt albúm er þegar til staðar",
"@dynamicAlbumAlreadyExists": {},
"selectStorageVolumeDialogTitle": "Veldu geymslu",
"@selectStorageVolumeDialogTitle": {},
"appExportDynamicAlbums": "Breytileg albúm",
"@appExportDynamicAlbums": {},
"mapAttributionOsmLiberty": "Kortaflísar frá [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Hýst hjá [OSM Americana](https://tile.ourmap.us)",
"@mapAttributionOsmLiberty": {},
"videoActionShowNextFrame": "Sýna næsta ramma",
"@videoActionShowNextFrame": {},
"mapAttributionOpenTopoMap": "[SRTM](https://www.earthdata.nasa.gov/sensors/srtm) | Kortaflísar frá [OpenTopoMap](https://opentopomap.org/), [CC BY-SA](https://creativecommons.org/licenses/by-sa/3.0/)",
"@mapAttributionOpenTopoMap": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"videoActionShowPreviousFrame": "Sýna fyrri ramma",
"@videoActionShowPreviousFrame": {},
"setHomeCustom": "Sérsniðið",
"@setHomeCustom": {},
"sortByDuration": "Eftir tímalengd",
"@sortByDuration": {},
"mapStyleOsmLiberty": "OSM Liberty",
"@mapStyleOsmLiberty": {},
"sortOrderLongestFirst": "Lengsta fyrst",
"@sortOrderLongestFirst": {},
"chipActionGoToExplorerPage": "Sýna í skráastjóra",
"@chipActionGoToExplorerPage": {},
"explorerPageTitle": "Skráastjóri",
"@explorerPageTitle": {},
"editEntryLocationDialogImportGpx": "Flytja inn GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Tímahliðrun",
"@editEntryLocationDialogTimeShift": {}
} }

View file

@ -1404,5 +1404,13 @@
"mapStyleOpenTopoMap": "OpenTopoMap", "mapStyleOpenTopoMap": "OpenTopoMap",
"@mapStyleOpenTopoMap": {}, "@mapStyleOpenTopoMap": {},
"mapAttributionOsmLiberty": "Tasselli di [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Ospitato da [OSM Americana](https://tile.ourmap.us)", "mapAttributionOsmLiberty": "Tasselli di [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Ospitato da [OSM Americana](https://tile.ourmap.us)",
"@mapAttributionOsmLiberty": {} "@mapAttributionOsmLiberty": {},
"chipActionDecompose": "Dividi",
"@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogTimeShift": "Scostamento tempo",
"@editEntryLocationDialogTimeShift": {},
"editEntryLocationDialogImportGpx": "Importa GPX",
"@editEntryLocationDialogImportGpx": {}
} }

View file

@ -205,9 +205,9 @@
"@filterMimeImageLabel": {}, "@filterMimeImageLabel": {},
"filterMimeVideoLabel": "동영상", "filterMimeVideoLabel": "동영상",
"@filterMimeVideoLabel": {}, "@filterMimeVideoLabel": {},
"coordinateFormatDms": "도초", "coordinateFormatDms": "도, 분, 초",
"@coordinateFormatDms": {}, "@coordinateFormatDms": {},
"coordinateFormatDecimal": "소수점", "coordinateFormatDecimal": "십신 도",
"@coordinateFormatDecimal": {}, "@coordinateFormatDecimal": {},
"coordinateDms": "{direction} {coordinate}", "coordinateDms": "{direction} {coordinate}",
"@coordinateDms": {}, "@coordinateDms": {},
@ -1406,5 +1406,13 @@
"appExportDynamicAlbums": "동적 앨범", "appExportDynamicAlbums": "동적 앨범",
"@appExportDynamicAlbums": {}, "@appExportDynamicAlbums": {},
"chipActionDecompose": "나누기", "chipActionDecompose": "나누기",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "도, 십진 분",
"@coordinateFormatDdm": {},
"editEntryLocationDialogTimeShift": "시간 이동",
"@editEntryLocationDialogTimeShift": {},
"editEntryLocationDialogImportGpx": "GPX 가져오기",
"@editEntryLocationDialogImportGpx": {},
"removeEntryMetadataDialogAll": "모두",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1408,5 +1408,13 @@
"chipActionRemove": "Verwijderen", "chipActionRemove": "Verwijderen",
"@chipActionRemove": {}, "@chipActionRemove": {},
"chipActionDecompose": "Splitsen", "chipActionDecompose": "Splitsen",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "GPX importeren",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Verschuiving van de tijd",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "Alle",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1564,5 +1564,13 @@
"appExportDynamicAlbums": "Dynamiczne albumy", "appExportDynamicAlbums": "Dynamiczne albumy",
"@appExportDynamicAlbums": {}, "@appExportDynamicAlbums": {},
"chipActionDecompose": "Podziel", "chipActionDecompose": "Podziel",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Importuj GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Przesunięcie czasowe",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "Wszystko",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1406,5 +1406,13 @@
"appExportDynamicAlbums": "Álbuns dinâmicos", "appExportDynamicAlbums": "Álbuns dinâmicos",
"@appExportDynamicAlbums": {}, "@appExportDynamicAlbums": {},
"chipActionDecompose": "Separar", "chipActionDecompose": "Separar",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Importar GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Salto temporal",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "Todos",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1536,5 +1536,31 @@
"explorerPageTitle": "Explorer", "explorerPageTitle": "Explorer",
"@explorerPageTitle": {}, "@explorerPageTitle": {},
"mapAttributionOsmData": "Datele hărții © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributori", "mapAttributionOsmData": "Datele hărții © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributori",
"@mapAttributionOsmData": {} "@mapAttributionOsmData": {},
"editEntryLocationDialogImportGpx": "Import GPX",
"@editEntryLocationDialogImportGpx": {},
"videoActionShowPreviousFrame": "Afișează cadrul anterior",
"@videoActionShowPreviousFrame": {},
"videoActionShowNextFrame": "Afișează următorul cadru",
"@videoActionShowNextFrame": {},
"mapAttributionOpenTopoMap": "[SRTM](https://www.earthdata.nasa.gov/sensors/srtm) | Plăci de la [OpenTopoMap](https://opentopomap.org/), [CC BY-SA](https://creativecommons.org/licenses/by-sa/3.0/)",
"@mapAttributionOpenTopoMap": {},
"chipActionDecompose": "Divizare",
"@chipActionDecompose": {},
"newDynamicAlbumDialogTitle": "Nou album dinamic",
"@newDynamicAlbumDialogTitle": {},
"dynamicAlbumAlreadyExists": "Albumul dinamic există deja",
"@dynamicAlbumAlreadyExists": {},
"collectionActionAddDynamicAlbum": "Adaugă album dinamic",
"@collectionActionAddDynamicAlbum": {},
"appExportDynamicAlbums": "Albume dinamice",
"@appExportDynamicAlbums": {},
"chipActionRemove": "Elimină",
"@chipActionRemove": {},
"albumTierDynamic": "Dinamic",
"@albumTierDynamic": {},
"newAlbumDialogAlbumAlreadyExistsHelper": "Albumul există deja",
"@newAlbumDialogAlbumAlreadyExistsHelper": {},
"mapAttributionOsmLiberty": "Plăci de la [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Găzduit de [OSM Americana](https://tile.ourmap.us)",
"@mapAttributionOsmLiberty": {}
} }

View file

@ -1563,6 +1563,14 @@
"@collectionActionAddDynamicAlbum": {}, "@collectionActionAddDynamicAlbum": {},
"appExportDynamicAlbums": "Динамічні альбоми", "appExportDynamicAlbums": "Динамічні альбоми",
"@appExportDynamicAlbums": {}, "@appExportDynamicAlbums": {},
"chipActionDecompose": "Спліт", "chipActionDecompose": "Розділити",
"@chipActionDecompose": {} "@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Імпорт GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Зсув часу",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "Все",
"@removeEntryMetadataDialogAll": {}
} }

View file

@ -1404,5 +1404,9 @@
"appExportDynamicAlbums": "动态专辑", "appExportDynamicAlbums": "动态专辑",
"@appExportDynamicAlbums": {}, "@appExportDynamicAlbums": {},
"dynamicAlbumAlreadyExists": "动态专辑已存在", "dynamicAlbumAlreadyExists": "动态专辑已存在",
"@dynamicAlbumAlreadyExists": {} "@dynamicAlbumAlreadyExists": {},
"chipActionDecompose": "分割",
"@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {}
} }

View file

@ -124,12 +124,14 @@ class Contributors {
Contributor('Grooty12', 'Rasmus@rosendahl-kaa.name'), Contributor('Grooty12', 'Rasmus@rosendahl-kaa.name'),
Contributor('Victor M', 'victormorita@tuta.io'), Contributor('Victor M', 'victormorita@tuta.io'),
Contributor('cat', 'catsnote@proton.me'), Contributor('cat', 'catsnote@proton.me'),
Contributor('Bruno Fragoso', 'darth_signa@hotmail.com'),
// Contributor('Femini', 'nizamismidov4@gmail.com'), // Azerbaijani // Contributor('Femini', 'nizamismidov4@gmail.com'), // Azerbaijani
// Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali // Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali
// Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese // Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese
// Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese // Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese
// Contributor('Åzze', 'laitinen.jere222@gmail.com'), // Finnish // Contributor('Åzze', 'laitinen.jere222@gmail.com'), // Finnish
// Contributor('Olli', 'ollinen@ollit.dev'), // Finnish // Contributor('Olli', 'ollinen@ollit.dev'), // Finnish
// Contributor('Ricky Tigg', 'ricky.tigg@gmail.com'), // Finnish
// Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew // Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew
// Contributor('Rohit Burman', 'rohitburman31p@rediffmail.com'), // Hindi // Contributor('Rohit Burman', 'rohitburman31p@rediffmail.com'), // Hindi
// Contributor('AJ07', 'ajaykumarmeena676@gmail.com'), // Hindi // Contributor('AJ07', 'ajaykumarmeena676@gmail.com'), // Hindi

View file

@ -333,6 +333,11 @@ class Dependencies {
license: mit, license: mit,
sourceUrl: 'https://github.com/fluttercommunity/get_it', sourceUrl: 'https://github.com/fluttercommunity/get_it',
), ),
Dependency(
name: 'GPX',
license: apache2,
sourceUrl: 'https://github.com/kb0/dart-gpx',
),
Dependency( Dependency(
name: 'HTTP', name: 'HTTP',
license: bsd3, license: bsd3,

View file

@ -1,7 +1,5 @@
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:floating/floating.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart'; import 'package:local_auth/local_auth.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@ -57,13 +55,6 @@ class Device {
final auth = LocalAuthentication(); final auth = LocalAuthentication();
_canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported(); _canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported();
try {
_supportPictureInPicture = await Floating().isPipAvailable;
} on PlatformException catch (_) {
// as of floating v2.0.0, plugin assumes activity and fails when bound via service
_supportPictureInPicture = false;
}
final capabilities = await deviceService.getCapabilities(); final capabilities = await deviceService.getCapabilities();
_canPinShortcut = capabilities['canPinShortcut'] ?? false; _canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
@ -74,5 +65,6 @@ class Device {
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false; _isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false; _supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false;
_supportPictureInPicture = capabilities['supportPictureInPicture'] ?? false;
} }
} }

View file

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:aves/model/entry/cache.dart'; import 'package:aves/model/entry/cache.dart';
import 'package:aves/model/entry/dirs.dart'; import 'package:aves/model/entry/dirs.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/metadata/address.dart'; import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart'; import 'package:aves/model/metadata/trash.dart';
@ -127,63 +128,63 @@ class AvesEntry with AvesEntryBase {
// from DB or platform source entry // from DB or platform source entry
factory AvesEntry.fromMap(Map map) { factory AvesEntry.fromMap(Map map) {
return AvesEntry( return AvesEntry(
id: map['id'] as int?, id: map[EntryFields.id] as int?,
uri: map['uri'] as String, uri: map[EntryFields.uri] as String,
path: map['path'] as String?, path: map[EntryFields.path] as String?,
pageId: null, pageId: null,
contentId: map['contentId'] as int?, contentId: map[EntryFields.contentId] as int?,
sourceMimeType: map['sourceMimeType'] as String, sourceMimeType: map[EntryFields.sourceMimeType] as String,
width: map['width'] as int? ?? 0, width: map[EntryFields.width] as int? ?? 0,
height: map['height'] as int? ?? 0, height: map[EntryFields.height] as int? ?? 0,
sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0, sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0,
sizeBytes: map['sizeBytes'] as int?, sizeBytes: map[EntryFields.sizeBytes] as int?,
sourceTitle: map['title'] as String?, sourceTitle: map[EntryFields.title] as String?,
dateAddedSecs: map['dateAddedSecs'] as int?, dateAddedSecs: map[EntryFields.dateAddedSecs] as int?,
dateModifiedSecs: map['dateModifiedSecs'] as int?, dateModifiedSecs: map[EntryFields.dateModifiedSecs] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?, sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?,
durationMillis: map['durationMillis'] as int?, durationMillis: map[EntryFields.durationMillis] as int?,
trashed: (map['trashed'] as int? ?? 0) != 0, trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
origin: map['origin'] as int, origin: map[EntryFields.origin] as int,
); );
} }
// for DB only // for DB only
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, EntryFields.id: id,
'uri': uri, EntryFields.uri: uri,
'path': path, EntryFields.path: path,
'contentId': contentId, EntryFields.contentId: contentId,
'sourceMimeType': sourceMimeType, EntryFields.sourceMimeType: sourceMimeType,
'width': width, EntryFields.width: width,
'height': height, EntryFields.height: height,
'sourceRotationDegrees': sourceRotationDegrees, EntryFields.sourceRotationDegrees: sourceRotationDegrees,
'sizeBytes': sizeBytes, EntryFields.sizeBytes: sizeBytes,
'title': sourceTitle, EntryFields.title: sourceTitle,
'dateAddedSecs': dateAddedSecs, EntryFields.dateAddedSecs: dateAddedSecs,
'dateModifiedSecs': dateModifiedSecs, EntryFields.dateModifiedSecs: dateModifiedSecs,
'sourceDateTakenMillis': sourceDateTakenMillis, EntryFields.sourceDateTakenMillis: sourceDateTakenMillis,
'durationMillis': durationMillis, EntryFields.durationMillis: durationMillis,
'trashed': trashed ? 1 : 0, EntryFields.trashed: trashed ? 1 : 0,
'origin': origin, EntryFields.origin: origin,
}; };
} }
Map<String, dynamic> toPlatformEntryMap() { Map<String, dynamic> toPlatformEntryMap() {
return { return {
'uri': uri, EntryFields.uri: uri,
'path': path, EntryFields.path: path,
'pageId': pageId, EntryFields.pageId: pageId,
'mimeType': mimeType, EntryFields.mimeType: mimeType,
'width': width, EntryFields.width: width,
'height': height, EntryFields.height: height,
'rotationDegrees': rotationDegrees, EntryFields.rotationDegrees: rotationDegrees,
'isFlipped': isFlipped, EntryFields.isFlipped: isFlipped,
'dateModifiedSecs': dateModifiedSecs, EntryFields.dateModifiedSecs: dateModifiedSecs,
'sizeBytes': sizeBytes, EntryFields.sizeBytes: sizeBytes,
'trashed': trashed, EntryFields.trashed: trashed,
'trashPath': trashDetails?.path, EntryFields.trashPath: trashDetails?.path,
'origin': origin, EntryFields.origin: origin,
}; };
} }
@ -402,34 +403,34 @@ class AvesEntry with AvesEntryBase {
final oldRotationDegrees = this.rotationDegrees; final oldRotationDegrees = this.rotationDegrees;
final oldIsFlipped = this.isFlipped; final oldIsFlipped = this.isFlipped;
final uri = newFields['uri']; final uri = newFields[EntryFields.uri];
if (uri is String) this.uri = uri; if (uri is String) this.uri = uri;
final path = newFields['path']; final path = newFields[EntryFields.path];
if (path is String) this.path = path; if (path is String) this.path = path;
final contentId = newFields['contentId']; final contentId = newFields[EntryFields.contentId];
if (contentId is int) this.contentId = contentId; if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title']; final sourceTitle = newFields[EntryFields.title];
if (sourceTitle is String) this.sourceTitle = sourceTitle; if (sourceTitle is String) this.sourceTitle = sourceTitle;
final sourceRotationDegrees = newFields['sourceRotationDegrees']; final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees];
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees; if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
final sourceDateTakenMillis = newFields['sourceDateTakenMillis']; final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis];
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis; if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
final width = newFields['width']; final width = newFields[EntryFields.width];
if (width is int) this.width = width; if (width is int) this.width = width;
final height = newFields['height']; final height = newFields[EntryFields.height];
if (height is int) this.height = height; if (height is int) this.height = height;
final durationMillis = newFields['durationMillis']; final durationMillis = newFields[EntryFields.durationMillis];
if (durationMillis is int) this.durationMillis = durationMillis; if (durationMillis is int) this.durationMillis = durationMillis;
final sizeBytes = newFields['sizeBytes']; final sizeBytes = newFields[EntryFields.sizeBytes];
if (sizeBytes is int) this.sizeBytes = sizeBytes; if (sizeBytes is int) this.sizeBytes = sizeBytes;
final dateModifiedSecs = newFields['dateModifiedSecs']; final dateModifiedSecs = newFields[EntryFields.dateModifiedSecs];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees']; final rotationDegrees = newFields[EntryFields.rotationDegrees];
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees; if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
final isFlipped = newFields['isFlipped']; final isFlipped = newFields[EntryFields.isFlipped];
if (isFlipped is bool) this.isFlipped = isFlipped; if (isFlipped is bool) this.isFlipped = isFlipped;
if (persist) { if (persist) {

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/media/geotiff.dart'; import 'package:aves/model/media/geotiff.dart';
import 'package:aves/model/metadata/catalog.dart'; import 'package:aves/model/metadata/catalog.dart';
@ -22,20 +23,20 @@ extension ExtraAvesEntryCatalog on AvesEntry {
final size = await SvgMetadataService.getSize(this); final size = await SvgMetadataService.getSize(this);
if (size != null) { if (size != null) {
final fields = { final fields = {
'width': size.width.ceil(), EntryFields.width: size.width.ceil(),
'height': size.height.ceil(), EntryFields.height: size.height.ceil(),
}; };
await applyNewFields(fields, persist: persist); await applyNewFields(fields, persist: persist);
} }
catalogMetadata = CatalogMetadata(id: id); catalogMetadata = CatalogMetadata(id: id);
} else { } else {
// pre-processing // pre-processing
if ((isVideo && (!isSized || durationMillis == 0)) || mimeType == MimeTypes.avif) { if (isVideo || mimeType == MimeTypes.avif) {
// exotic video that is not sized during loading // on initial loading, original source may report incorrect size, rotation or duration
final fields = await VideoMetadataFormatter.getLoadingMetadata(this); final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
// check size as the video interpreter may fail on some AVIF stills // check size as the video interpreter may fail on some AVIF stills
final width = fields['width']; final width = fields[EntryFields.width];
final height = fields['height']; final height = fields[EntryFields.height];
final isValid = (width == null || width > 0) && (height == null || height > 0); final isValid = (width == null || width > 0) && (height == null || height > 0);
if (isValid) { if (isValid) {
await applyNewFields(fields, persist: persist); await applyNewFields(fields, persist: persist);
@ -47,7 +48,7 @@ extension ExtraAvesEntryCatalog on AvesEntry {
// post-processing // post-processing
if ((isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) || (mimeType == MimeTypes.avif && durationMillis != null)) { if ((isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) || (mimeType == MimeTypes.avif && durationMillis != null)) {
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this); catalogMetadata = await VideoMetadataFormatter.completeCatalogMetadata(this);
} }
if (isGeotiff && !hasGps) { if (isGeotiff && !hasGps) {
final info = await metadataFetchService.getGeoTiffInfo(this); final info = await metadataFetchService.getGeoTiffInfo(this);

View file

@ -67,13 +67,13 @@ extension ExtraAvesEntryImages on AvesEntry {
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail(); return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
} }
// magic number used to derive sample size from scale static int sampleSizeForScale({
static const scaleFactor = 2.0; required double magnifierScale,
required double devicePixelRatio,
static int sampleSizeForScale(double scale) { }) {
var sample = 0; var sample = 0;
if (0 < scale && scale < 1) { if (0 < magnifierScale && magnifierScale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor); sample = highestPowerOf2(1 / (magnifierScale * devicePixelRatio));
} }
return max<int>(1, sample); return max<int>(1, sample);
} }

View file

@ -0,0 +1,28 @@
// entry fields exported and imported from/to the platform side
// should match `EntryFields` on platform side
class EntryFields {
static const id = 'id'; // int
static const origin = 'origin'; // int
static const uri = 'uri'; // string
static const contentId = 'contentId'; // long
static const path = 'path'; // string
static const pageId = 'pageId'; // int
static const sourceMimeType = 'sourceMimeType'; // string
static const mimeType = 'mimeType'; // string
static const width = 'width'; // int
static const height = 'height'; // int
static const sourceRotationDegrees = 'sourceRotationDegrees'; // int
static const rotationDegrees = 'rotationDegrees'; // int
static const isFlipped = 'isFlipped'; // boolean
static const dateAddedSecs = 'dateAddedSecs'; // long
static const dateModifiedSecs = 'dateModifiedSecs'; // long
static const sourceDateTakenMillis = 'sourceDateTakenMillis'; // long
static const durationMillis = 'durationMillis'; // long
static const sizeBytes = 'sizeBytes'; // long
static const trashed = 'trashed'; // boolean
static const trashPath = 'trashPath'; // string
static const title = 'title'; // string
}

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/media/video/channel_layouts.dart'; import 'package:aves/model/media/video/channel_layouts.dart';
import 'package:aves/model/media/video/codecs.dart'; import 'package:aves/model/media/video/codecs.dart';
import 'package:aves/model/media/video/profiles/aac.dart'; import 'package:aves/model/media/video/profiles/aac.dart';
@ -46,6 +47,7 @@ class VideoMetadataFormatter {
Codecs.webm: 'WebM', Codecs.webm: 'WebM',
}; };
// fetch size, rotation and duration
static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async { static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async {
final mediaInfo = await videoMetadataFetcher.getMetadata(entry); final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
final fields = <String, int>{}; final fields = <String, int>{};
@ -58,25 +60,31 @@ class VideoMetadataFormatter {
final width = sizedStream[Keys.videoWidth]; final width = sizedStream[Keys.videoWidth];
final height = sizedStream[Keys.videoHeight]; final height = sizedStream[Keys.videoHeight];
if (width is int && height is int) { if (width is int && height is int) {
fields['width'] = width; fields[EntryFields.width] = width;
fields['height'] = height; fields[EntryFields.height] = height;
}
final rotationDegrees = sizedStream[Keys.rotate];
if (rotationDegrees is int) {
fields[EntryFields.rotationDegrees] = rotationDegrees;
} }
} }
} }
final durationMicros = mediaInfo[Keys.durationMicros]; final durationMicros = mediaInfo[Keys.durationMicros];
if (durationMicros is num) { if (durationMicros is num) {
fields['durationMillis'] = (durationMicros / 1000).round(); fields[EntryFields.durationMillis] = (durationMicros / 1000).round();
} else { } else {
final duration = _parseDuration(mediaInfo[Keys.duration]); final duration = _parseDuration(mediaInfo[Keys.duration]);
if (duration != null && duration > Duration.zero) { if (duration != null && duration > Duration.zero) {
fields['durationMillis'] = duration.inMilliseconds; fields[EntryFields.durationMillis] = duration.inMilliseconds;
} }
} }
return fields; return fields;
} }
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async { // fetch date and animated status
static Future<CatalogMetadata?> completeCatalogMetadata(AvesEntry entry) async {
var catalogMetadata = entry.catalogMetadata ?? CatalogMetadata(id: entry.id); var catalogMetadata = entry.catalogMetadata ?? CatalogMetadata(id: entry.id);
final mediaInfo = await videoMetadataFetcher.getMetadata(entry); final mediaInfo = await videoMetadataFetcher.getMetadata(entry);

View file

@ -0,0 +1,7 @@
import 'package:aves_model/aves_model.dart';
mixin DebugSettings on SettingsAccess {
bool get debugShowViewerTiles => getBool(SettingKeys.debugShowViewerTilesKey) ?? false;
set debugShowViewerTiles(bool newValue) => set(SettingKeys.debugShowViewerTilesKey, newValue);
}

View file

@ -12,6 +12,7 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart';
import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/settings/modules/app.dart'; import 'package:aves/model/settings/modules/app.dart';
import 'package:aves/model/settings/modules/collection.dart'; import 'package:aves/model/settings/modules/collection.dart';
import 'package:aves/model/settings/modules/debug.dart';
import 'package:aves/model/settings/modules/display.dart'; import 'package:aves/model/settings/modules/display.dart';
import 'package:aves/model/settings/modules/filter_grids.dart'; import 'package:aves/model/settings/modules/filter_grids.dart';
import 'package:aves/model/settings/modules/info.dart'; import 'package:aves/model/settings/modules/info.dart';
@ -40,7 +41,7 @@ import 'package:latlong2/latlong.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings, NavigationSettings, SearchSettings, CollectionSettings, FilterGridsSettings, PrivacySettings, ViewerSettings, VideoSettings, SubtitlesSettings, InfoSettings { class Settings with ChangeNotifier, SettingsAccess, DebugSettings, AppSettings, DisplaySettings, NavigationSettings, SearchSettings, CollectionSettings, FilterGridsSettings, PrivacySettings, ViewerSettings, VideoSettings, SubtitlesSettings, InfoSettings {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change'); final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast(); final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();

View file

@ -4,6 +4,7 @@ import 'dart:ui';
import 'package:aves/model/covers.dart'; import 'package:aves/model/covers.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/catalog.dart'; import 'package:aves/model/entry/extensions/catalog.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/sort.dart'; import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/favourites.dart'; import 'package:aves/model/favourites.dart';
@ -242,29 +243,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
newFields.keys.forEach((key) { newFields.keys.forEach((key) {
final newValue = newFields[key]; final newValue = newFields[key];
switch (key) { switch (key) {
case 'contentId': case EntryFields.contentId:
entry.contentId = newValue as int?; entry.contentId = newValue as int?;
case 'dateModifiedSecs': case EntryFields.dateModifiedSecs:
// `dateModifiedSecs` changes when moving entries to another directory, // `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory // but it does not change when renaming the containing directory
entry.dateModifiedSecs = newValue as int?; entry.dateModifiedSecs = newValue as int?;
case 'path': case EntryFields.path:
entry.path = newValue as String?; entry.path = newValue as String?;
case 'title': case EntryFields.title:
entry.sourceTitle = newValue as String?; entry.sourceTitle = newValue as String?;
case 'trashed': case EntryFields.trashed:
final trashed = newValue as bool; final trashed = newValue as bool;
entry.trashed = trashed; entry.trashed = trashed;
entry.trashDetails = trashed entry.trashDetails = trashed
? TrashDetails( ? TrashDetails(
id: entry.id, id: entry.id,
path: newFields['trashPath'] as String, path: newFields[EntryFields.trashPath] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch, dateMillis: DateTime.now().millisecondsSinceEpoch,
) )
: null; : null;
case 'uri': case EntryFields.uri:
entry.uri = newValue as String; entry.uri = newValue as String;
case 'origin': case EntryFields.origin:
entry.origin = newValue as int; entry.origin = newValue as int;
} }
}); });
@ -341,7 +342,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
if (movedOps.isEmpty) return; if (movedOps.isEmpty) return;
final replacedUris = movedOps final replacedUris = movedOps
.map((movedOp) => movedOp.newFields['path'] as String?) .map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
.map((targetPath) { .map((targetPath) {
final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed); final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed);
return existingEntry?.uri; return existingEntry?.uri;
@ -362,14 +363,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
fromAlbums.add(sourceEntry.directory); fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry.copyWith( movedEntries.add(sourceEntry.copyWith(
id: localMediaDb.nextId, id: localMediaDb.nextId,
uri: newFields['uri'] as String?, uri: newFields[EntryFields.uri] as String?,
path: newFields['path'] as String?, path: newFields[EntryFields.path] as String?,
contentId: newFields['contentId'] as int?, contentId: newFields[EntryFields.contentId] as int?,
// title can change when moved files are automatically renamed to avoid conflict // title can change when moved files are automatically renamed to avoid conflict
title: newFields['title'] as String?, title: newFields[EntryFields.title] as String?,
dateAddedSecs: newFields['dateAddedSecs'] as int?, dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int?, dateModifiedSecs: newFields[EntryFields.dateModifiedSecs] as int?,
origin: newFields['origin'] as int?, origin: newFields[EntryFields.origin] as int?,
)); ));
} else { } else {
debugPrint('failed to find source entry with uri=$sourceUri'); debugPrint('failed to find source entry with uri=$sourceUri');
@ -386,7 +387,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri); final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) { if (entry != null) {
if (moveType == MoveType.fromBin) { if (moveType == MoveType.fromBin) {
newFields['trashed'] = false; newFields[EntryFields.trashed] = false;
} else { } else {
fromAlbums.add(entry.directory); fromAlbums.add(entry.directory);
} }

View file

@ -11,11 +11,18 @@ String formatDateTime(DateTime date, String locale, bool use24hour) => [
].join(AText.separator); ].join(AText.separator);
String formatFriendlyDuration(Duration d) { String formatFriendlyDuration(Duration d) {
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0'); final isNegative = d.isNegative;
if (d.inHours == 0) return '${d.inMinutes}:$seconds'; final sign = isNegative ? '-' : '';
d = d.abs();
final hours = d.inHours;
d -= Duration(hours: hours);
final minutes = d.inMinutes;
d -= Duration(minutes: minutes);
final seconds = d.inSeconds;
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0'); if (hours == 0) return '$sign$minutes:${seconds.toString().padLeft(2, '0')}';
return '${d.inHours}:$minutes:$seconds';
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
} }
String formatPreciseDuration(Duration d) { String formatPreciseDuration(Duration d) {

View file

@ -9,6 +9,7 @@ extension ExtraLocationEditActionView on LocationEditAction {
LocationEditAction.chooseOnMap => l10n.editEntryLocationDialogChooseOnMap, LocationEditAction.chooseOnMap => l10n.editEntryLocationDialogChooseOnMap,
LocationEditAction.copyItem => l10n.editEntryDialogCopyFromItem, LocationEditAction.copyItem => l10n.editEntryDialogCopyFromItem,
LocationEditAction.setCustom => l10n.editEntryLocationDialogSetCustom, LocationEditAction.setCustom => l10n.editEntryLocationDialogSetCustom,
LocationEditAction.importGpx => l10n.editEntryLocationDialogImportGpx,
LocationEditAction.remove => l10n.actionRemove, LocationEditAction.remove => l10n.actionRemove,
}; };
} }

View file

@ -154,10 +154,12 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
final flavor = context.read<AppFlavor>().toString().split('.')[1]; final flavor = context.read<AppFlavor>().toString().split('.')[1];
final packageInfo = await PackageInfo.fromPlatform(); final packageInfo = await PackageInfo.fromPlatform();
final androidInfo = await DeviceInfoPlugin().androidInfo; final androidInfo = await DeviceInfoPlugin().androidInfo;
final mediaQuery = MediaQuery.of(context);
final view = View.of(context);
final supportsHdr = await windowService.supportsHdr();
final connections = await Connectivity().checkConnectivity(); final connections = await Connectivity().checkConnectivity();
final storageVolumes = await storageService.getStorageVolumes(); final storageVolumes = await storageService.getStorageVolumes();
final storageGrants = await storageService.getGrantedDirectories(); final storageGrants = await storageService.getGrantedDirectories();
final supportsHdr = await windowService.supportsHdr();
return [ return [
'Package: ${device.packageName}', 'Package: ${device.packageName}',
'Installer: ${packageInfo.installerStore}', 'Installer: ${packageInfo.installerStore}',
@ -166,6 +168,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
'Android version: ${androidInfo.version.release}, API ${androidInfo.version.sdkInt}', 'Android version: ${androidInfo.version.release}, API ${androidInfo.version.sdkInt}',
'Android build: ${androidInfo.display}', 'Android build: ${androidInfo.display}',
'Device: ${androidInfo.manufacturer} ${androidInfo.model}', 'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
'Display: pixel ratio=${view.devicePixelRatio}, logical=${mediaQuery.size.width}x${mediaQuery.size.height}, physical=${view.physicalSize.width}x${view.physicalSize.height}',
'Support: dynamic colors=${device.isDynamicColorAvailable}, geocoder=${device.hasGeocoder}, HDR=$supportsHdr', 'Support: dynamic colors=${device.isDynamicColorAvailable}, geocoder=${device.hasGeocoder}, HDR=$supportsHdr',
'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}', 'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}',
'Connectivity: ${connections.map((v) => v.name).join(', ')}', 'Connectivity: ${connections.map((v) => v.name).join(', ')}',

View file

@ -152,10 +152,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
@override @override
void didChangeMetrics() { void didChangeMetrics() {
// when top padding changes // when top padding or text scale factor change
_updateStatusBarHeight(); WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight());
// when text scale factor changes
_updateAppBarHeight();
} }
@override @override

View file

@ -292,26 +292,29 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final details = vaults.getVault(entry.directory); final details = vaults.getVault(entry.directory);
return details?.useBin ?? settings.enableBin; return details?.useBin ?? settings.enableBin;
}); });
await Future.forEach( var completed = true;
byBinUsage.entries, await Future.forEach(byBinUsage.entries, (kv) async {
(kv) => doDelete( completed &= await doDelete(
context: context, context: context,
entries: kv.value.toSet(), entries: kv.value.toSet(),
enableBin: kv.key, enableBin: kv.key,
)); );
});
if (completed) {
_browse(context); _browse(context);
} }
}
Future<void> doDelete({ // returns whether it completed the action (with or without failures)
Future<bool> doDelete({
required BuildContext context, required BuildContext context,
required Set<AvesEntry> entries, required Set<AvesEntry> entries,
required bool enableBin, required bool enableBin,
}) async { }) async {
final pureTrash = entries.every((entry) => entry.trashed); final pureTrash = entries.every((entry) => entry.trashed);
if (enableBin && !pureTrash) { if (enableBin && !pureTrash) {
await doMove(context, moveType: MoveType.toBin, entries: entries); return await doMove(context, moveType: MoveType.toBin, entries: entries);
return;
} }
final l10n = context.l10n; final l10n = context.l10n;
@ -325,10 +328,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
message: l10n.deleteEntriesConfirmationDialogMessage(todoCount), message: l10n.deleteEntriesConfirmationDialogMessage(todoCount),
confirmationButtonLabel: l10n.deleteButtonLabel, confirmationButtonLabel: l10n.deleteButtonLabel,
)) { )) {
return; return false;
} }
if (!await checkStoragePermissionForAlbums(context, storageDirs, entries: entries)) return; if (!await checkStoragePermissionForAlbums(context, storageDirs, entries: entries)) return false;
source.pauseMonitoring(); source.pauseMonitoring();
final opId = mediaEditService.newOpId; final opId = mediaEditService.newOpId;
@ -338,9 +341,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaEditService.cancelFileOp(opId), onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((op) => op.success).toSet();
final deletedOps = successOps.where((e) => !e.skipped).toSet(); final deletedOps = successOps.where((op) => !op.skipped).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet(); final deletedUris = deletedOps.map((op) => op.uri).toSet();
await source.removeEntries(deletedUris, includeTrash: true); await source.removeEntries(deletedUris, includeTrash: true);
source.resumeMonitoring(); source.resumeMonitoring();
@ -354,14 +357,17 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
await storageService.deleteEmptyRegularDirectories(storageDirs); await storageService.deleteEmptyRegularDirectories(storageDirs);
}, },
); );
return true;
} }
Future<void> _move(BuildContext context, {required MoveType moveType}) async { Future<void> _move(BuildContext context, {required MoveType moveType}) async {
final entries = _getTargetItems(context); final entries = _getTargetItems(context);
await doMove(context, moveType: moveType, entries: entries); final completed = await doMove(context, moveType: moveType, entries: entries);
if (completed) {
_browse(context); _browse(context);
} }
}
Future<void> _rename(BuildContext context) async { Future<void> _rename(BuildContext context) async {
final entries = _getTargetItems(context).toList(); final entries = _getTargetItems(context).toList();
@ -381,10 +387,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
return MapEntry(entry, '$newName${entry.extension}'); return MapEntry(entry, '$newName${entry.extension}');
}); });
final entriesToNewName = Map.fromEntries(await Future.wait(namingFutures)).whereNotNullValue(); final entriesToNewName = Map.fromEntries(await Future.wait(namingFutures)).whereNotNullValue();
await rename(context, entriesToNewName: entriesToNewName, persist: true); final completed = await rename(context, entriesToNewName: entriesToNewName, persist: true);
if (completed) {
_browse(context); _browse(context);
} }
}
Future<void> _convert(BuildContext context) async { Future<void> _convert(BuildContext context) async {
final entries = _getTargetItems(context); final entries = _getTargetItems(context);
@ -398,13 +406,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
switch (options.action) { switch (options.action) {
case EntryConvertAction.convert: case EntryConvertAction.convert:
await doExport(context, entries, options); final completed = await doExport(context, entries, options);
if (completed) {
_browse(context);
}
case EntryConvertAction.convertMotionPhotoToStillImage: case EntryConvertAction.convertMotionPhotoToStillImage:
final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet(); final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet();
await _edit(context, todoItems, (entry) => entry.removeTrailerVideo()); await _edit(context, todoItems, (entry) => entry.removeTrailerVideo());
} }
_browse(context);
} }
Future<void> _toggleFavourite(BuildContext context) async { Future<void> _toggleFavourite(BuildContext context) async {
@ -451,11 +460,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
itemCount: todoCount, itemCount: todoCount,
onCancel: () => cancelled = true, onCancel: () => cancelled = true,
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((op) => op.success).toSet();
final editedOps = successOps.where((e) => !e.skipped).toSet(); final editedOps = successOps.where((op) => !op.skipped).toSet();
source.resumeMonitoring(); source.resumeMonitoring();
unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet()).then((_) { unawaited(source.refreshUris(editedOps.map((op) => op.uri).toSet()).then((_) {
// invalidate filters derived from values before edition // invalidate filters derived from values before edition
// this invalidation must happen after the source is refreshed, // this invalidation must happen after the source is refreshed,
// otherwise filter chips may eagerly rebuild in between with the old state // otherwise filter chips may eagerly rebuild in between with the old state
@ -563,10 +572,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (entries == null || entries.isEmpty) return; if (entries == null || entries.isEmpty) return;
final collection = context.read<CollectionLens>(); final collection = context.read<CollectionLens>();
final location = await selectLocation(context, entries, collection); final locationByEntry = await selectLocation(context, entries, collection);
if (location == null) return; if (locationByEntry == null) return;
await _edit(context, entries, (entry) => entry.editLocation(location)); await _edit(context, locationByEntry.keys.toSet(), (entry) => entry.editLocation(locationByEntry[entry]));
} }
Future<LatLng?> editLocationByMap(BuildContext context, Set<AvesEntry> entries, LatLng clusterLocation, CollectionLens mapCollection) async { Future<LatLng?> editLocationByMap(BuildContext context, Set<AvesEntry> entries, LatLng clusterLocation, CollectionLens mapCollection) async {

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/metadata_edition.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart';
import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/filters/covered/tag.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/placeholder.dart'; import 'package:aves/model/filters/placeholder.dart';
import 'package:aves/model/filters/covered/tag.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
@ -17,9 +17,7 @@ import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart';
import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart'; import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart';
import 'package:aves_model/aves_model.dart'; import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
mixin EntryEditorMixin { mixin EntryEditorMixin {
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async { Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
@ -35,15 +33,13 @@ mixin EntryEditorMixin {
); );
} }
Future<LatLng?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async { Future<LocationEditActionResult?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
if (entries.isEmpty) return null; if (entries.isEmpty) return null;
final entry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first; return showDialog<LocationEditActionResult>(
return showDialog<LatLng>(
context: context, context: context,
builder: (context) => EditEntryLocationDialog( builder: (context) => EditEntryLocationDialog(
entry: entry, entries: entries,
collection: collection, collection: collection,
), ),
routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName), routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName),

View file

@ -3,8 +3,11 @@ import 'dart:io';
import 'package:aves/app_mode.dart'; import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/favourites.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/multipage.dart';
import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/extensions/props.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/filters/covered/stored_album.dart';
import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
@ -37,14 +40,15 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Future<void> doExport(BuildContext context, Set<AvesEntry> targetEntries, EntryConvertOptions options) async { // returns whether it completed the action (with or without failures)
Future<bool> doExport(BuildContext context, Set<AvesEntry> targetEntries, EntryConvertOptions options) async {
final destinationAlbumFilter = await pickAlbum(context: context, moveType: MoveType.export, storedAlbumsOnly: true); final destinationAlbumFilter = await pickAlbum(context: context, moveType: MoveType.export, storedAlbumsOnly: true);
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return; if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return false;
final destinationAlbum = destinationAlbumFilter.album; final destinationAlbum = destinationAlbumFilter.album;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return false;
if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return; if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return false;
final transientMultiPageInfo = <MultiPageInfo>{}; final transientMultiPageInfo = <MultiPageInfo>{};
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
@ -89,7 +93,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
), ),
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName), routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
); );
if (value == null) return; if (value == null) return false;
nameConflictStrategy = value; nameConflictStrategy = value;
} }
@ -106,13 +110,28 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
), ),
itemCount: selectionCount, itemCount: selectionCount,
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((op) => op.success).toSet();
final exportedOps = successOps.where((e) => !e.skipped).toSet(); final exportedOps = successOps.where((op) => !op.skipped && op.newFields[EntryFields.uri] != null).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).nonNulls.toSet(); final newUris = exportedOps.map((op) => op.newFields[EntryFields.uri] as String).toSet();
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main; final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
// check source favourite status
final favouriteSourceUris = selection.where((entry) => entry.isFavourite).map((entry) => entry.uri).toSet();
final favouriteNewUris = <String>{};
exportedOps.forEach((op) {
final sourceUri = op.uri;
if (favouriteSourceUris.contains(sourceUri)) {
final newUri = op.newFields[EntryFields.uri] as String;
favouriteNewUris.add(newUri);
}
});
source.resumeMonitoring(); source.resumeMonitoring();
unawaited(source.refreshUris(newUris)); unawaited(source.refreshUris(newUris).then((_) {
// transfer favourite status on exports
final newFavouriteEntries = source.allEntries.where((entry) => favouriteNewUris.contains(entry.uri)).toSet();
favourites.add(newFavouriteEntries);
}));
// get navigator beforehand because // get navigator beforehand because
// local context may be deactivated when action is triggered after navigation // local context may be deactivated when action is triggered after navigation
@ -157,34 +176,44 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
}, },
); );
transientMultiPageInfo.forEach((v) => v.dispose()); transientMultiPageInfo.forEach((v) => v.dispose());
return true;
} }
Future<void> doQuickMove( // returns whether it completed the action (with or without failures)
Future<bool> doQuickMove(
BuildContext context, { BuildContext context, {
required MoveType moveType, required MoveType moveType,
required Map<String, Iterable<AvesEntry>> entriesByDestination, required Map<String, Set<AvesEntry>> entriesByDestination,
bool hideShowAction = false, bool hideShowAction = false,
VoidCallback? onSuccess, VoidCallback? onSuccess,
}) async { }) async {
if (moveType == MoveType.move) {
// skip moving entries to their directory
entriesByDestination.forEach((destinationAlbum, entries) {
entries.removeWhere((entry) => entry.directory == destinationAlbum);
});
entriesByDestination.removeWhere((_, entries) => entries.isEmpty);
}
final entries = entriesByDestination.values.expand((v) => v).toSet(); final entries = entriesByDestination.values.expand((v) => v).toSet();
final todoCount = entries.length; final todoCount = entries.length;
assert(todoCount > 0); if (todoCount == 0) return true;
final toBin = moveType == MoveType.toBin; final toBin = moveType == MoveType.toBin;
final copy = moveType == MoveType.copy; final copy = moveType == MoveType.copy;
// permission for modification at destinations // permission for modification at destinations
final destinationAlbums = entriesByDestination.keys.toSet(); final destinationAlbums = entriesByDestination.keys.toSet();
if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return; if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return false;
// permission for modification at origins // permission for modification at origins
final originAlbums = entries.map((e) => e.directory).nonNulls.toSet(); final originAlbums = entries.map((e) => e.directory).nonNulls.toSet();
if ({MoveType.move, MoveType.toBin}.contains(moveType) && !await checkStoragePermissionForAlbums(context, originAlbums, entries: entries)) return; if ({MoveType.move, MoveType.toBin}.contains(moveType) && !await checkStoragePermissionForAlbums(context, originAlbums, entries: entries)) return false;
final hasEnoughSpaceByDestination = await Future.wait(destinationAlbums.map((destinationAlbum) { final hasEnoughSpaceByDestination = await Future.wait(destinationAlbums.map((destinationAlbum) {
return checkFreeSpaceForMove(context, entries, destinationAlbum, moveType); return checkFreeSpaceForMove(context, entries, destinationAlbum, moveType);
})); }));
if (hasEnoughSpaceByDestination.any((v) => !v)) return; if (hasEnoughSpaceByDestination.any((v) => !v)) return false;
final l10n = context.l10n; final l10n = context.l10n;
var nameConflictStrategy = NameConflictStrategy.rename; var nameConflictStrategy = NameConflictStrategy.rename;
@ -209,12 +238,12 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
), ),
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName), routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
); );
if (value == null) return; if (value == null) return false;
nameConflictStrategy = value; nameConflictStrategy = value;
} }
} }
if ({MoveType.move, MoveType.copy}.contains(moveType) && !await _checkUndatedItems(context, entries)) return; if ({MoveType.move, MoveType.copy}.contains(moveType) && !await _checkUndatedItems(context, entries)) return false;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
source.pauseMonitoring(); source.pauseMonitoring();
@ -230,11 +259,11 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaEditService.cancelFileOp(opId), onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((v) => v.success).toSet(); final successOps = processed.where((op) => op.success).toSet();
// move // move
final movedOps = successOps.where((v) => !v.skipped && !v.deleted).toSet(); final movedOps = successOps.where((op) => !op.skipped && !op.deleted).toSet();
final movedEntries = movedOps.map((v) => v.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).nonNulls.toSet(); final movedEntries = movedOps.map((op) => op.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).nonNulls.toSet();
await source.updateAfterMove( await source.updateAfterMove(
todoEntries: entries, todoEntries: entries,
moveType: moveType, moveType: moveType,
@ -243,8 +272,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
); );
// delete (when trying to move to bin obsolete entries) // delete (when trying to move to bin obsolete entries)
final deletedOps = successOps.where((v) => v.deleted).toSet(); final deletedOps = successOps.where((op) => op.deleted).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet(); final deletedUris = deletedOps.map((op) => op.uri).toSet();
await source.removeEntries(deletedUris, includeTrash: true); await source.removeEntries(deletedUris, includeTrash: true);
source.resumeMonitoring(); source.resumeMonitoring();
@ -313,9 +342,11 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
} }
}, },
); );
return true;
} }
Future<void> doMove( // returns whether it completed the action (with or without failures)
Future<bool> doMove(
BuildContext context, { BuildContext context, {
required MoveType moveType, required MoveType moveType,
required Set<AvesEntry> entries, required Set<AvesEntry> entries,
@ -330,7 +361,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
message: l10n.binEntriesConfirmationDialogMessage(entries.length), message: l10n.binEntriesConfirmationDialogMessage(entries.length),
confirmationButtonLabel: l10n.deleteButtonLabel, confirmationButtonLabel: l10n.deleteButtonLabel,
)) { )) {
return; return false;
} }
} }
@ -340,7 +371,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
case MoveType.move: case MoveType.move:
case MoveType.export: case MoveType.export:
final destinationAlbumFilter = await pickAlbum(context: context, moveType: moveType, storedAlbumsOnly: true); final destinationAlbumFilter = await pickAlbum(context: context, moveType: moveType, storedAlbumsOnly: true);
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return; if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return false;
final destinationAlbum = destinationAlbumFilter.album; final destinationAlbum = destinationAlbumFilter.album;
settings.recentDestinationAlbums = settings.recentDestinationAlbums settings.recentDestinationAlbums = settings.recentDestinationAlbums
@ -357,7 +388,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
}); });
} }
await doQuickMove( return await doQuickMove(
context, context,
moveType: moveType, moveType: moveType,
entriesByDestination: entriesByDestination, entriesByDestination: entriesByDestination,
@ -365,7 +396,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
); );
} }
Future<void> rename( // returns whether it completed the action (with or without failures)
Future<bool> rename(
BuildContext context, { BuildContext context, {
required Map<AvesEntry, String> entriesToNewName, required Map<AvesEntry, String> entriesToNewName,
required bool persist, required bool persist,
@ -375,9 +407,9 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final todoCount = entries.length; final todoCount = entries.length;
assert(todoCount > 0); assert(todoCount > 0);
if (!await checkStoragePermission(context, entries)) return; if (!await checkStoragePermission(context, entries)) return false;
if (!await _checkUndatedItems(context, entries)) return; if (!await _checkUndatedItems(context, entries)) return false;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
source.pauseMonitoring(); source.pauseMonitoring();
@ -391,8 +423,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
itemCount: todoCount, itemCount: todoCount,
onCancel: () => mediaEditService.cancelFileOp(opId), onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async { onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet(); final successOps = processed.where((op) => op.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet(); final movedOps = successOps.where((op) => !op.skipped).toSet();
await source.updateAfterRename( await source.updateAfterRename(
todoEntries: entries, todoEntries: entries,
movedOps: movedOps, movedOps: movedOps,
@ -412,6 +444,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
} }
}, },
); );
return true;
} }
Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async { Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async {
@ -451,7 +484,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Set<String> destinationAlbums, Set<String> destinationAlbums,
Set<MoveOpEvent> movedOps, Set<MoveOpEvent> movedOps,
) async { ) async {
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); final newUris = movedOps.map((op) => op.newFields[EntryFields.uri] as String?).toSet();
bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri); bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri);
final collection = context.read<CollectionLens?>(); final collection = context.read<CollectionLens?>();

View file

@ -0,0 +1,152 @@
import 'package:aves/ref/locales.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/widgets/common/basic/wheel.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
class TimeShiftSelector extends StatefulWidget {
final TimeShiftController controller;
const TimeShiftSelector({
super.key,
required this.controller,
});
@override
State<TimeShiftSelector> createState() => _TimeShiftSelectorState();
}
class _TimeShiftSelectorState extends State<TimeShiftSelector> {
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
late ValueNotifier<String> _shiftSign;
static const _positiveSign = '+';
static const _negativeSign = '-';
@override
void initState() {
super.initState();
var initialValue = widget.controller.initialValue;
final sign = initialValue.isNegative ? _negativeSign : _positiveSign;
initialValue = initialValue.abs();
final hours = initialValue.inHours;
initialValue -= Duration(hours: hours);
final minutes = initialValue.inMinutes;
initialValue -= Duration(minutes: minutes);
final seconds = initialValue.inSeconds;
_shiftSign = ValueNotifier(sign);
_shiftHour = ValueNotifier(hours);
_shiftMinute = ValueNotifier(minutes);
_shiftSecond = ValueNotifier(seconds);
_shiftSign.addListener(_updateValue);
_shiftHour.addListener(_updateValue);
_shiftMinute.addListener(_updateValue);
_shiftSecond.addListener(_updateValue);
}
@override
void dispose() {
_shiftSign.dispose();
_shiftHour.dispose();
_shiftMinute.dispose();
_shiftSecond.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final timeComponentFormatter = NumberFormat('0', context.locale);
const textStyle = TextStyle(fontSize: 34);
const digitsAlign = TextAlign.right;
return Center(
child: Table(
textDirection: timeComponentsDirection,
children: [
TableRow(
children: [
const SizedBox(),
Center(child: Text(l10n.durationDialogHours)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogMinutes)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogSeconds)),
],
),
TableRow(
children: [
WheelSelector(
valueNotifier: _shiftSign,
values: const [_positiveSign, _negativeSign],
textStyle: textStyle,
textAlign: TextAlign.center,
format: (v) => v,
),
Align(
alignment: Alignment.centerRight,
child: WheelSelector(
valueNotifier: _shiftHour,
values: List.generate(hoursInDay, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftMinute,
values: List.generate(minutesInHour, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftSecond,
values: List.generate(secondsInMinute, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
],
)
],
defaultColumnWidth: const IntrinsicColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
),
);
}
void _updateValue() {
final sign = _shiftSign.value == _positiveSign ? 1 : -1;
final hours = _shiftHour.value;
final minutes = _shiftMinute.value;
final seconds = _shiftSecond.value;
widget.controller.value = Duration(hours: hours, minutes: minutes, seconds: seconds) * sign;
}
}
class TimeShiftController {
final Duration initialValue;
Duration value;
TimeShiftController({required this.initialValue}) : value = initialValue;
}

View file

@ -5,10 +5,10 @@ class AvesBorder {
static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26; static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26;
// 1 device pixel for straight lines is fine // 1 device pixel for straight lines is fine
static double straightBorderWidth(BuildContext context) => 1 / View.of(context).devicePixelRatio; static double straightBorderWidth(BuildContext context) => 1 / MediaQuery.devicePixelRatioOf(context);
// 1 device pixel for curves is too thin // 1 device pixel for curves is too thin
static double curvedBorderWidth(BuildContext context) => View.of(context).devicePixelRatio > 2 ? 0.5 : 1.0; static double curvedBorderWidth(BuildContext context) => MediaQuery.devicePixelRatioOf(context) > 2 ? 0.5 : 1.0;
static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide( static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide(
color: _borderColor(context), color: _borderColor(context),

View file

@ -42,6 +42,7 @@ class GeoMap extends StatefulWidget {
final ValueNotifier<LatLng?>? dotLocationNotifier; final ValueNotifier<LatLng?>? dotLocationNotifier;
final ValueNotifier<double>? overlayOpacityNotifier; final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry; final MapOverlay? overlayEntry;
final Set<List<LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange; final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap; final MapTapCallback? onMapTap;
final void Function( final void Function(
@ -69,6 +70,7 @@ class GeoMap extends StatefulWidget {
this.dotLocationNotifier, this.dotLocationNotifier,
this.overlayOpacityNotifier, this.overlayOpacityNotifier,
this.overlayEntry, this.overlayEntry,
this.tracks,
this.onUserZoomChange, this.onUserZoomChange,
this.onMapTap, this.onMapTap,
this.onMarkerTap, this.onMarkerTap,
@ -135,7 +137,7 @@ class _GeoMapState extends State<GeoMap> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final devicePixelRatio = View.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
void onMarkerLongPress(GeoEntry<AvesEntry> geoEntry, LatLng tapLocation) => _onMarkerLongPress( void onMarkerLongPress(GeoEntry<AvesEntry> geoEntry, LatLng tapLocation) => _onMarkerLongPress(
geoEntry: geoEntry, geoEntry: geoEntry,
tapLocation: tapLocation, tapLocation: tapLocation,
@ -179,6 +181,7 @@ class _GeoMapState extends State<GeoMap> {
dotLocationNotifier: widget.dotLocationNotifier, dotLocationNotifier: widget.dotLocationNotifier,
overlayOpacityNotifier: widget.overlayOpacityNotifier, overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry, overlayEntry: widget.overlayEntry,
tracks: widget.tracks,
onUserZoomChange: widget.onUserZoomChange, onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap, onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap, onMarkerTap: _onMarkerTap,
@ -210,6 +213,7 @@ class _GeoMapState extends State<GeoMap> {
), ),
overlayOpacityNotifier: widget.overlayOpacityNotifier, overlayOpacityNotifier: widget.overlayOpacityNotifier,
overlayEntry: widget.overlayEntry, overlayEntry: widget.overlayEntry,
tracks: widget.tracks,
onUserZoomChange: widget.onUserZoomChange, onUserZoomChange: widget.onUserZoomChange,
onMapTap: widget.onMapTap, onMapTap: widget.onMapTap,
onMarkerTap: _onMarkerTap, onMarkerTap: _onMarkerTap,

View file

@ -30,6 +30,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
final Size markerSize, dotMarkerSize; final Size markerSize, dotMarkerSize;
final ValueNotifier<double>? overlayOpacityNotifier; final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry; final MapOverlay? overlayEntry;
final Set<List<LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange; final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap; final MapTapCallback? onMapTap;
final MarkerTapCallback<T>? onMarkerTap; final MarkerTapCallback<T>? onMarkerTap;
@ -52,6 +53,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
required this.dotMarkerSize, required this.dotMarkerSize,
this.overlayOpacityNotifier, this.overlayOpacityNotifier,
this.overlayEntry, this.overlayEntry,
this.tracks,
this.onUserZoomChange, this.onUserZoomChange,
this.onMapTap, this.onMapTap,
this.onMarkerTap, this.onMarkerTap,
@ -175,6 +177,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
children: [ children: [
_buildMapLayer(), _buildMapLayer(),
if (widget.overlayEntry != null) _buildOverlayImageLayer(), if (widget.overlayEntry != null) _buildOverlayImageLayer(),
if (widget.tracks != null) _buildTracksLayer(),
MarkerLayer( MarkerLayer(
markers: markers, markers: markers,
rotate: true, rotate: true,
@ -243,6 +246,22 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
); );
} }
Widget _buildTracksLayer() {
final tracks = widget.tracks;
if (tracks == null) return const SizedBox();
final trackColor = Theme.of(context).colorScheme.primary;
return PolylineLayer(
polylines: tracks
.map((v) => Polyline(
points: v,
strokeWidth: MapThemeData.trackWidth.toDouble(),
color: trackColor,
))
.toList(),
);
}
void _onBoundsChanged() => _debouncer(_onIdle); void _onBoundsChanged() => _debouncer(_onIdle);
void _onIdle() { void _onIdle() {

View file

@ -1,7 +1,9 @@
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/analysis_service.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/debug/overlay.dart'; import 'package:aves/widgets/debug/overlay.dart';
import 'package:aves/widgets/settings/common/tiles.dart';
import 'package:aves/widgets/viewer/info/common.dart'; import 'package:aves/widgets/viewer/info/common.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -61,6 +63,11 @@ class _DebugGeneralSectionState extends State<DebugGeneralSection> with Automati
}, },
title: const Text('Show tasks overlay'), title: const Text('Show tasks overlay'),
), ),
SettingsSwitchListTile(
selector: (context, s) => s.debugShowViewerTiles,
onChanged: (v) => settings.debugShowViewerTiles = v,
title: 'Show viewer tiles',
),
ElevatedButton( ElevatedButton(
onPressed: () => LeakTracking.collectLeaks().then((leaks) { onPressed: () => LeakTracking.collectLeaks().then((leaks) {
const config = LeakDiagnosticConfig( const config = LeakDiagnosticConfig(

View file

@ -1,24 +1,21 @@
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/metadata/date_modifier.dart'; import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/locales.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart'; import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/view/view.dart'; import 'package:aves/view/view.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart'; import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/basic/wheel.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart'; import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/common/basic/time_shift_selector.dart';
import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:aves/widgets/dialogs/item_picker.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
import 'package:aves_model/aves_model.dart'; import 'package:aves_model/aves_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class EditEntryDateDialog extends StatefulWidget { class EditEntryDateDialog extends StatefulWidget {
@ -42,17 +39,13 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate; DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
late AvesEntry _copyItemSource; late AvesEntry _copyItemSource;
late DateTime _customDateTime; late DateTime _customDateTime;
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond; late TimeShiftController _timeShiftController;
late ValueNotifier<String> _shiftSign;
bool _showOptions = false; bool _showOptions = false;
final Set<MetadataField> _fields = {...DateModifier.writableFields}; final Set<MetadataField> _fields = {...DateModifier.writableFields};
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now(); DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
static const _positiveSign = '+';
static const _negativeSign = '-';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -65,10 +58,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
@override @override
void dispose() { void dispose() {
_isValidNotifier.dispose(); _isValidNotifier.dispose();
_shiftHour.dispose();
_shiftMinute.dispose();
_shiftSecond.dispose();
_shiftSign.dispose();
super.dispose(); super.dispose();
} }
@ -81,10 +70,9 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
} }
void _initShift() { void _initShift() {
_shiftHour = ValueNotifier(1); _timeShiftController = TimeShiftController(
_shiftMinute = ValueNotifier(0); initialValue: const Duration(hours: 1),
_shiftSecond = ValueNotifier(0); );
_shiftSign = ValueNotifier(_positiveSign);
} }
@override @override
@ -203,80 +191,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
} }
Widget _buildShiftContent(BuildContext context) { Widget _buildShiftContent(BuildContext context) {
final l10n = context.l10n; return TimeShiftSelector(controller: _timeShiftController);
final timeComponentFormatter = NumberFormat('0', context.locale);
const textStyle = TextStyle(fontSize: 34);
const digitsAlign = TextAlign.right;
return Center(
child: Table(
textDirection: timeComponentsDirection,
children: [
TableRow(
children: [
const SizedBox(),
Center(child: Text(l10n.durationDialogHours)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogMinutes)),
const SizedBox(width: 16),
Center(child: Text(l10n.durationDialogSeconds)),
],
),
TableRow(
children: [
WheelSelector(
valueNotifier: _shiftSign,
values: const [_positiveSign, _negativeSign],
textStyle: textStyle,
textAlign: TextAlign.center,
format: (v) => v,
),
Align(
alignment: Alignment.centerRight,
child: WheelSelector(
valueNotifier: _shiftHour,
values: List.generate(hoursInDay, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftMinute,
values: List.generate(minutesInHour, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
const Text(
':',
style: textStyle,
),
Align(
alignment: Alignment.centerLeft,
child: WheelSelector(
valueNotifier: _shiftSecond,
values: List.generate(secondsInMinute, (i) => i),
textStyle: textStyle,
textAlign: digitsAlign,
format: timeComponentFormatter.format,
),
),
],
)
],
defaultColumnWidth: const IntrinsicColumnWidth(),
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
),
);
} }
Widget _buildDestinationFields(BuildContext context) { Widget _buildDestinationFields(BuildContext context) {
@ -368,7 +283,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
fullscreenDialog: true, fullscreenDialog: true,
), ),
); );
pickCollection.dispose();
if (entry != null) { if (entry != null) {
setState(() => _copyItemSource = entry); setState(() => _copyItemSource = entry);
} }
@ -388,8 +302,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
case DateEditAction.extractFromTitle: case DateEditAction.extractFromTitle:
return DateModifier.extractFromTitle(); return DateModifier.extractFromTitle();
case DateEditAction.shift: case DateEditAction.shift:
final shiftTotalSeconds = ((_shiftHour.value * minutesInHour + _shiftMinute.value) * secondsInMinute + _shiftSecond.value) * (_shiftSign.value == _positiveSign ? 1 : -1); return DateModifier.shift(_fields, _timeShiftController.value.inSeconds);
return DateModifier.shift(_fields, shiftTotalSeconds);
case DateEditAction.remove: case DateEditAction.remove:
return DateModifier.remove(_fields); return DateModifier.remove(_fields);
} }

View file

@ -1,29 +1,40 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/location.dart'; import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/entry/extensions/metadata_edition.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart';
import 'package:aves/model/entry/sort.dart';
import 'package:aves/model/filters/covered/location.dart'; import 'package:aves/model/filters/covered/location.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/settings/enums/coordinate_format.dart'; import 'package:aves/model/settings/enums/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/poi.dart'; import 'package:aves/ref/poi.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart'; import 'package:aves/theme/themes.dart';
import 'package:aves/view/view.dart'; import 'package:aves/view/view.dart';
import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/basic/text_dropdown_button.dart'; import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/fx/transitions.dart'; import 'package:aves/widgets/common/fx/transitions.dart';
import 'package:aves/widgets/common/identity/aves_caption.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/dialogs/item_picker.dart'; import 'package:aves/widgets/dialogs/item_picker.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart';
import 'package:aves/widgets/dialogs/time_shift_dialog.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves_model/aves_model.dart'; import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gpx/gpx.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -31,12 +42,12 @@ import 'package:provider/provider.dart';
class EditEntryLocationDialog extends StatefulWidget { class EditEntryLocationDialog extends StatefulWidget {
static const routeName = '/dialog/edit_entry_location'; static const routeName = '/dialog/edit_entry_location';
final AvesEntry entry; final Set<AvesEntry> entries;
final CollectionLens? collection; final CollectionLens? collection;
const EditEntryLocationDialog({ const EditEntryLocationDialog({
super.key, super.key,
required this.entry, required this.entries,
this.collection, this.collection,
}); });
@ -44,19 +55,26 @@ class EditEntryLocationDialog extends StatefulWidget {
State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState(); State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState();
} }
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> { class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> with FeedbackMixin {
final List<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
LocationEditAction _action = LocationEditAction.chooseOnMap; LocationEditAction _action = LocationEditAction.chooseOnMap;
LatLng? _mapCoordinates; LatLng? _mapCoordinates;
late final AvesEntry mainEntry;
late AvesEntry _copyItemSource; late AvesEntry _copyItemSource;
Gpx? _gpx;
Duration _gpxShift = Duration.zero;
final Map<AvesEntry, LatLng> _gpxMap = {};
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController(); final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale); NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale);
static const _minTimeToGpxPoint = Duration(hours: 1);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final entries = widget.entries;
mainEntry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
_initMapCoordinates(); _initMapCoordinates();
_initCopyItem(); _initCopyItem();
_initCustom(); _initCustom();
@ -64,16 +82,16 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
} }
void _initMapCoordinates() { void _initMapCoordinates() {
_mapCoordinates = widget.entry.latLng; _mapCoordinates = mainEntry.latLng;
} }
void _initCopyItem() { void _initCopyItem() {
_copyItemSource = widget.entry; _copyItemSource = mainEntry;
} }
void _initCustom() { void _initCustom() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final latLng = widget.entry.latLng; final latLng = mainEntry.latLng;
if (latLng != null) { if (latLng != null) {
_latitudeController.text = coordinateFormatter.format(latLng.latitude); _latitudeController.text = coordinateFormatter.format(latLng.latitude);
_longitudeController.text = coordinateFormatter.format(latLng.longitude); _longitudeController.text = coordinateFormatter.format(latLng.longitude);
@ -128,14 +146,9 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
switchInCurve: Curves.easeInOutCubic, switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic, switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder, transitionBuilder: AvesTransitions.formTransitionBuilder,
child: Column( child: KeyedSubtree(
key: ValueKey(_action), key: ValueKey(_action),
mainAxisSize: MainAxisSize.min, child: _buildContent(),
children: [
if (_action == LocationEditAction.chooseOnMap) _buildChooseOnMapContent(context),
if (_action == LocationEditAction.copyItem) _buildCopyItemContent(context),
if (_action == LocationEditAction.setCustom) _buildSetCustomContent(context),
],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@ -158,12 +171,27 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
); );
} }
Widget _buildContent() {
switch (_action) {
case LocationEditAction.chooseOnMap:
return _buildChooseOnMapContent(context);
case LocationEditAction.copyItem:
return _buildCopyItemContent(context);
case LocationEditAction.setCustom:
return _buildSetCustomContent(context);
case LocationEditAction.importGpx:
return _buildImportGpxContent(context);
case LocationEditAction.remove:
return const SizedBox();
}
}
Widget _buildChooseOnMapContent(BuildContext context) { Widget _buildChooseOnMapContent(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8), padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Row( child: Row(
children: [ children: [
Expanded(child: _toText(context, _mapCoordinates)), Expanded(child: _coordinatesText(context, _mapCoordinates)),
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
icon: const Icon(AIcons.map), icon: const Icon(AIcons.map),
@ -179,8 +207,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
_latitudeController.text = coordinateFormatter.format(latLng.latitude); _latitudeController.text = coordinateFormatter.format(latLng.latitude);
_longitudeController.text = coordinateFormatter.format(latLng.longitude); _longitudeController.text = coordinateFormatter.format(latLng.longitude);
_action = LocationEditAction.setCustom; _action = LocationEditAction.setCustom;
_validate(); setState(_validate);
setState(() {});
} }
CollectionLens? _createPickCollection() { CollectionLens? _createPickCollection() {
@ -208,7 +235,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
fullscreenDialog: true, fullscreenDialog: true,
), ),
); );
pickCollection?.dispose();
if (latLng != null) { if (latLng != null) {
settings.mapDefaultCenter = latLng; settings.mapDefaultCenter = latLng;
setState(() { setState(() {
@ -223,7 +249,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
padding: const EdgeInsetsDirectional.only(start: 16, end: 8), padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Row( child: Row(
children: [ children: [
Expanded(child: _toText(context, _copyItemSource.latLng)), Expanded(child: _coordinatesText(context, _copyItemSource.latLng)),
const SizedBox(width: 8), const SizedBox(width: 8),
ItemPicker( ItemPicker(
extent: 48, extent: 48,
@ -249,7 +275,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
fullscreenDialog: true, fullscreenDialog: true,
), ),
); );
pickCollection.dispose();
if (entry != null) { if (entry != null) {
setState(() { setState(() {
_copyItemSource = entry; _copyItemSource = entry;
@ -293,13 +318,207 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
); );
} }
Text _toText(BuildContext context, LatLng? latLng) { Widget _buildImportGpxContent(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
if (latLng != null) { return Padding(
return Text( padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'), child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: _gpxDateRangeText(context, _gpx)),
const SizedBox(width: 8),
IconButton(
icon: Icon(AIcons.fileImport),
onPressed: _pickGpx,
tooltip: l10n.pickTooltip,
),
],
),
if (_gpx != null) ...[
Row(
children: [
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.editEntryLocationDialogTimeShift),
AvesCaption(_formatShiftDuration(_gpxShift)),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(AIcons.edit),
onPressed: _pickGpxShift,
tooltip: l10n.changeTooltip,
),
],
),
Row(
children: [
Expanded(child: Text(l10n.statsWithGps(_gpxMap.length))),
const SizedBox(width: 8),
IconButton(
icon: const Icon(AIcons.map),
onPressed: _previewGpx,
tooltip: l10n.openMapPageTooltip,
),
],
),
],
],
),
); );
}
Future<void> _pickGpx() async {
final bytes = await storageService.openFile();
if (bytes.isNotEmpty) {
try {
final allXmlString = utf8.decode(bytes);
final gpx = GpxReader().fromString(allXmlString);
_gpx = gpx;
_gpxShift = Duration.zero;
_updateGpxMapping();
showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback);
} catch (error, stack) {
debugPrint('failed to import GPX, error=$error\n$stack');
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
}
}
}
Future<void> _pickGpxShift() async {
final newShift = await showDialog<Duration>(
context: context,
builder: (context) => TimeShiftDialog(
initialValue: _gpxShift,
),
routeSettings: const RouteSettings(name: TimeShiftDialog.routeName),
);
if (newShift == null) return;
_gpxShift = newShift;
_updateGpxMapping();
}
String _formatShiftDuration(Duration duration) {
final sign = duration.isNegative ? '-' : '+';
duration = duration.abs();
final hours = duration.inHours;
duration -= Duration(hours: hours);
final minutes = duration.inMinutes;
duration -= Duration(minutes: minutes);
final seconds = duration.inSeconds;
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
void _updateGpxMapping() {
_gpxMap.clear();
final gpx = _gpx;
if (gpx == null) return;
final Map<AvesEntry, Wpt> wptByEntry = {};
// dated items and points, oldest first
final sortedEntries = widget.entries.where((v) => v.bestDate != null).sorted(AvesEntrySort.compareByDate).reversed.toList();
final sortedPoints = gpx.trks.expand((trk) => trk.trksegs).expand((trkSeg) => trkSeg.trkpts).where((v) => v.time != null).sortedBy((v) => v.time!);
if (sortedEntries.isNotEmpty && sortedPoints.isNotEmpty) {
int entryIndex = 0;
int pointIndex = 0;
final int maxDurationSecs = const Duration(days: 365).inSeconds;
int smallestDifferenceSecs = maxDurationSecs;
while (entryIndex < sortedEntries.length && pointIndex < sortedPoints.length) {
final entry = sortedEntries[entryIndex];
final point = sortedPoints[pointIndex];
final entryDate = entry.bestDate!;
final pointTime = point.time!.add(_gpxShift);
final differenceSecs = entryDate.difference(pointTime).inSeconds.abs();
if (differenceSecs < smallestDifferenceSecs) {
smallestDifferenceSecs = differenceSecs;
wptByEntry[entry] = point;
pointIndex++;
} else { } else {
smallestDifferenceSecs = maxDurationSecs;
entryIndex++;
}
}
}
_gpxMap.addEntries(wptByEntry.entries.map((kv) {
final entry = kv.key;
final wpt = kv.value;
final timeToPoint = entry.bestDate!.difference(wpt.time!.add(_gpxShift)).abs();
if (timeToPoint < _minTimeToGpxPoint) {
final lat = wpt.lat;
final lon = wpt.lon;
if (lat != null && lon != null) {
return MapEntry(entry, LatLng(lat, lon));
}
}
return null;
}).nonNulls);
setState(_validate);
}
Future<void> _previewGpx() async {
final source = widget.collection?.source;
if (source == null) return;
final previewEntries = _gpxMap.entries.map((kv) {
final entry = kv.key.copyWith();
final latLng = kv.value;
final catalogMetadata = entry.catalogMetadata?.copyWith() ?? CatalogMetadata(id: entry.id);
catalogMetadata.latitude = latLng.latitude;
catalogMetadata.longitude = latLng.longitude;
entry.catalogMetadata = catalogMetadata;
return entry;
}).toList();
final mapCollection = CollectionLens(
source: source,
listenToSource: false,
fixedSelection: previewEntries,
);
final tracks = _gpx?.trks
.expand((trk) => trk.trksegs)
.map((trkSeg) => trkSeg.trkpts
.map((wpt) {
final lat = wpt.lat;
final lon = wpt.lon;
return (lat != null && lon != null) ? LatLng(lat, lon) : null;
})
.nonNulls
.toList())
.toSet();
await Navigator.maybeOf(context)?.push(
MaterialPageRoute(
settings: const RouteSettings(name: LocationPickPage.routeName),
builder: (context) {
return ListenableProvider<ValueNotifier<AppMode>>.value(
value: ValueNotifier(AppMode.previewMap),
child: MapPage(
collection: mapCollection,
tracks: tracks,
),
);
},
fullscreenDialog: true,
),
);
}
Text _unknownText(BuildContext context) {
final l10n = context.l10n;
return Text( return Text(
l10n.viewerInfoUnknown, l10n.viewerInfoUnknown,
style: TextStyle( style: TextStyle(
@ -307,6 +526,39 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
), ),
); );
} }
(DateTime, DateTime)? _gpxDateRange(Gpx? gpx) {
final firstDate = gpx?.trks.firstOrNull?.trksegs.firstOrNull?.trkpts.firstOrNull?.time;
final lastDate = gpx?.trks.lastOrNull?.trksegs.lastOrNull?.trkpts.lastOrNull?.time;
return firstDate != null && lastDate != null ? (firstDate, lastDate) : null;
}
Text _gpxDateRangeText(BuildContext context, Gpx? gpx) {
final dateRange = _gpxDateRange(gpx);
if (dateRange != null) {
final (firstDate, lastDate) = dateRange;
final locale = context.locale;
final use24hour = MediaQuery.alwaysUse24HourFormatOf(context);
return Text(
[
formatDateTime(firstDate.toLocal(), locale, use24hour),
formatDateTime(lastDate.toLocal(), locale, use24hour),
].join('\n'),
);
} else {
return _unknownText(context);
}
}
Text _coordinatesText(BuildContext context, LatLng? latLng) {
final l10n = context.l10n;
if (latLng != null) {
return Text(
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
);
} else {
return _unknownText(context);
}
} }
LatLng? _parseLatLng() { LatLng? _parseLatLng() {
@ -334,6 +586,8 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
_isValidNotifier.value = _copyItemSource.hasGps; _isValidNotifier.value = _copyItemSource.hasGps;
case LocationEditAction.setCustom: case LocationEditAction.setCustom:
_isValidNotifier.value = _parseLatLng() != null; _isValidNotifier.value = _parseLatLng() != null;
case LocationEditAction.importGpx:
_isValidNotifier.value = _gpxMap.isNotEmpty;
case LocationEditAction.remove: case LocationEditAction.remove:
_isValidNotifier.value = true; _isValidNotifier.value = true;
} }
@ -341,15 +595,23 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
void _submit(BuildContext context) { void _submit(BuildContext context) {
final navigator = Navigator.maybeOf(context); final navigator = Navigator.maybeOf(context);
final entries = widget.entries;
final LocationEditActionResult result = {};
void addLocationForAllEntries(LatLng? latLng) => result.addEntries(entries.map((v) => MapEntry(v, latLng)));
switch (_action) { switch (_action) {
case LocationEditAction.chooseOnMap: case LocationEditAction.chooseOnMap:
navigator?.pop(_mapCoordinates); addLocationForAllEntries(_mapCoordinates);
case LocationEditAction.copyItem: case LocationEditAction.copyItem:
navigator?.pop(_copyItemSource.latLng); addLocationForAllEntries(_copyItemSource.latLng);
case LocationEditAction.setCustom: case LocationEditAction.setCustom:
navigator?.pop(_parseLatLng()); addLocationForAllEntries(_parseLatLng());
case LocationEditAction.importGpx:
result.addAll(_gpxMap);
case LocationEditAction.remove: case LocationEditAction.remove:
navigator?.pop(ExtraAvesEntryMetadataEdition.removalLocation); addLocationForAllEntries(ExtraAvesEntryMetadataEdition.removalLocation);
} }
navigator?.pop(result);
} }
} }
typedef LocationEditActionResult = Map<AvesEntry, LatLng?>;

View file

@ -29,7 +29,7 @@ class RemoveEntryMetadataDialog extends StatefulWidget {
} }
class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> { class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
late final List<MetadataType> _mainOptions, _moreOptions; late final List<MetadataType> _allOptions, _mainOptions, _moreOptions;
final Set<MetadataType> _types = {}; final Set<MetadataType> _types = {};
bool _showMore = false; bool _showMore = false;
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
@ -37,10 +37,11 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final byMain = groupBy([ _allOptions = [
...MetadataTypes.common, ...MetadataTypes.common,
if (widget.showJpegTypes) ...MetadataTypes.jpeg, if (widget.showJpegTypes) ...MetadataTypes.jpeg,
], MetadataTypes.main.contains); ];
final byMain = groupBy(_allOptions, MetadataTypes.main.contains);
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText); _mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText); _moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);
_validate(); _validate();
@ -59,6 +60,17 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
return AvesDialog( return AvesDialog(
title: l10n.removeEntryMetadataDialogTitle, title: l10n.removeEntryMetadataDialogTitle,
scrollableContent: [ scrollableContent: [
SwitchListTile(
value: _types.length == _allOptions.length,
onChanged: (selected) {
selected ? _types.addAll(_allOptions) : _types.clear();
setState(_validate);
},
title: Align(
alignment: Alignment.centerLeft,
child: Text(l10n.removeEntryMetadataDialogAll),
),
),
..._mainOptions.map(_toTile), ..._mainOptions.map(_toTile),
if (_moreOptions.isNotEmpty) if (_moreOptions.isNotEmpty)
Padding( Padding(
@ -131,8 +143,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
value: _types.contains(type), value: _types.contains(type),
onChanged: (selected) { onChanged: (selected) {
selected ? _types.add(type) : _types.remove(type); selected ? _types.add(type) : _types.remove(type);
_validate(); setState(_validate);
setState(() {});
}, },
title: Align( title: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,

View file

@ -33,17 +33,18 @@ class ItemPickPage extends StatefulWidget {
class _ItemPickPageState extends State<ItemPickPage> { class _ItemPickPageState extends State<ItemPickPage> {
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization); final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization);
CollectionLens get collection => widget.collection;
@override @override
void dispose() { void dispose() {
collection.dispose();
_appModeNotifier.dispose(); _appModeNotifier.dispose();
// provided collection should be a new instance specifically created
// for the `ItemPickPage` widget, so it can be safely disposed here
widget.collection.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final collection = widget.collection;
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
_appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal; _appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal;
return ListenableProvider<ValueNotifier<AppMode>>.value( return ListenableProvider<ValueNotifier<AppMode>>.value(

View file

@ -99,6 +99,9 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
_isPageAnimatingNotifier.dispose(); _isPageAnimatingNotifier.dispose();
_dotLocationNotifier.dispose(); _dotLocationNotifier.dispose();
_infoLocationNotifier.dispose(); _infoLocationNotifier.dispose();
// provided collection should be a new instance specifically created
// for the `LocationPickPage` widget, so it can be safely disposed here
widget.collection?.dispose();
super.dispose(); super.dispose();
} }

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