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
steps:
- name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with:
egress-policy: audit

View file

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

View file

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

View file

@ -31,7 +31,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
with:
egress-policy: audit
@ -71,6 +71,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- 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:
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="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
### Added

View file

@ -36,7 +36,7 @@ android {
namespace 'deckers.thibault.aves'
compileSdk 35
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
ndkVersion '27.0.12077973'
ndkVersion '28.0.12916984'
defaultConfig {
applicationId packageName
@ -151,7 +151,7 @@ repositories {
}
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.core:core-ktx:1.15.0'
@ -173,13 +173,13 @@ dependencies {
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
// - https://jitpack.io/p/deckerst/mp4parser
// - 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:muxer:d5caf7a3dd'
implementation 'com.github.deckerst:pixymeta-android:9ec7097f17'
implementation 'com.github.deckerst:pixymeta-android:71eee77dc4'
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'
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.Resources
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
@ -16,6 +15,7 @@ import android.os.Looper
import android.util.Log
import android.util.SizeF
import android.widget.RemoteViews
import androidx.core.net.toUri
import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
import deckers.thibault.aves.channel.calls.DeviceHandler
@ -83,7 +83,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
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) {
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@ -102,7 +102,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
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(
@ -116,13 +116,14 @@ class HomeWidgetProvider : AppWidgetProvider() {
if (sizesDip.isEmpty()) return null
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 params = hashMapOf(
"widgetId" to widgetId,
"sizesDip" to sizesDip,
"sizesDip" to sizesDipMap,
"devicePixelRatio" to getDevicePixelRatio(),
"drawEntryImage" to drawEntryImage,
"reuseEntry" to reuseEntry,
@ -259,7 +260,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
}
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))
return PendingIntent.getBroadcast(
@ -276,7 +277,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
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
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)
return PendingIntent.getActivity(

View file

@ -69,6 +69,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import androidx.core.net.toUri
// `FlutterFragmentActivity` because of local auth plugin
open class MainActivity : FlutterFragmentActivity() {
@ -442,7 +443,7 @@ open class MainActivity : FlutterFragmentActivity() {
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 firstUri = toUri(pickedUris.first())
if (pickedUris.size == 1) {

View file

@ -5,7 +5,15 @@ import android.util.Log
import android.view.View
import app.loup.streams_channel.StreamsChannel
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.WindowHandler
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 io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.*
import java.util.*
import kotlinx.coroutines.CoroutineScope
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.resumeWithException
import kotlin.coroutines.suspendCoroutine

View file

@ -7,6 +7,7 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.getParcelableExtraCompat
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import androidx.core.net.toUri
class WallpaperActivity : MainActivity() {
private var originalIntent: String? = null
@ -39,7 +40,7 @@ class WallpaperActivity : MainActivity() {
if (originalIntent != null) {
val pickedUris = call.argument<List<String>>("uris")
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 {
action = originalIntent
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.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
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) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val uri = call.argument<String>("uri")?.toUri()
val label = call.argument<String>("label")
if (uri == 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) {
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 forceChooser = call.argument<Boolean>("forceChooser")
if (uri == null || forceChooser == null) {
@ -236,7 +237,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
}
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) {
result.error("openMap-args", "missing arguments", null)
return
@ -250,7 +251,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
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")
if (uri == null) {
result.error("setAs-args", "missing arguments", null)
@ -273,7 +274,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
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()
// 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
val filters = call.argument<List<String>>("filters")
val explorerPath = call.argument<String>("path")
val viewUri = call.argument<String>("viewUri")?.let { Uri.parse(it) }
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
val viewUri = call.argument<String>("viewUri")?.toUri()
val geoUri = call.argument<String>("geoUri")?.toUri()
if (label == null || route == null) {
result.error("pin-args", "missing arguments", null)

View file

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

View file

@ -4,14 +4,15 @@ import android.app.LocaleConfig
import android.app.LocaleManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Resources
import android.location.Geocoder
import android.net.Uri
import android.os.Build
import android.os.LocaleList
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.net.toUri
import com.google.android.material.color.DynamicColors
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
@ -24,7 +25,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.Locale
import java.util.TimeZone
class DeviceHandler(private val context: Context) : MethodCallHandler {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -62,10 +62,17 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
"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) {
fun toMap(locale: Locale): FieldMap = hashMapOf(
"language" to locale.language,
@ -130,7 +137,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
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)
result.success(true)
}

View file

@ -1,9 +1,9 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException
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.XMP.getSafeStructField
import deckers.thibault.aves.metadata.xmp.XMPPropName
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.provider.ImageProvider
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) {
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()
if (mimeType == null || uri == 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) {
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 displayName = call.argument<String>("displayName")
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) {
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 displayName = call.argument<String>("displayName")
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) {
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 displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) {
@ -185,7 +186,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return
}
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val imageSizeBytes = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
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) {
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 displayName = call.argument<String>("displayName")
if (mimeType == null || uri == null || sizeBytes == null) {
@ -206,7 +207,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return
}
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(videoStartOffset)
@ -219,7 +220,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
}
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")
if (uri == 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) {
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 displayName = call.argument<String>("displayName")
val dataProp = call.argument<List<Any>>("propPath")
@ -329,8 +330,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
FileProvider.getUriForFile(context, authority, targetFile)
}
val resultFields: FieldMap = hashMapOf(
"uri" to uri.toString(),
"mimeType" to mimeType,
EntryFields.URI to uri.toString(),
EntryFields.MIME_TYPE to mimeType,
)
if (isImage(mimeType) || isVideo(mimeType)) {
val provider = getProvider(context, uri)

View file

@ -1,8 +1,8 @@
package deckers.thibault.aves.channel.calls
import android.content.ContextWrapper
import android.net.Uri
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.safeSuspend
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) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val uri = call.argument<String>("uri")?.toUri()
val desiredName = call.argument<String>("desiredName")
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
val bytes = call.argument<ByteArray>("bytes")

View file

@ -2,7 +2,7 @@ package deckers.thibault.aves.channel.calls
import android.content.Context
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.fetchers.RegionFetcher
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) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val uri = call.argument<String>("uri")?.toUri()
val mimeType = call.argument<String>("mimeType")
val pageId = call.argument<Int>("pageId")
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
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) {
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
if (uri == null) {
result.error("getEntry-args", "missing arguments", null)

View file

@ -1,12 +1,16 @@
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.session.PlaybackState
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.net.toUri
import androidx.media.session.MediaButtonReceiver
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
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) {
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 durationMillis = call.argument<Number>("durationMillis")?.toLong()
val stateString = call.argument<String>("state")

View file

@ -1,7 +1,7 @@
package deckers.thibault.aves.channel.calls
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.metadata.Mp4TooLargeException
import deckers.thibault.aves.model.ExifOrientationOp
@ -54,7 +54,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
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 mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
@ -82,7 +82,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
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 mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
@ -109,7 +109,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
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 mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
@ -134,7 +134,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
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 mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {
@ -160,7 +160,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
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 mimeType = entryMap["mimeType"] as String?
if (uri == null || path == null || mimeType == null) {

View file

@ -107,6 +107,7 @@ import java.util.Locale
import kotlin.math.roundToInt
import kotlin.math.roundToLong
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
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) {
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()
if (mimeType == null || uri == null) {
result.error("getAllMetadata-args", "missing arguments", null)
@ -516,7 +517,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// - XMP / MicrosoftPhoto:Rating
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
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 sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
if (mimeType == null || uri == null) {
@ -869,7 +870,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
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 fields = call.argument<List<String>>("fields")
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) {
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()
if (mimeType == null || uri == 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) {
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 isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
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) {
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()
if (mimeType == null || uri == 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) {
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) {
result.error("getIptc-args", "missing arguments", null)
return
@ -1146,7 +1147,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// return an empty list if there is no XMP
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
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()
if (mimeType == null || uri == 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) {
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")
if (mimeType == null || uri == null || prop == 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) {
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 field = call.argument<String>("field")
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) {
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 fields = call.argument<List<String>>("fields")
if (mimeType == null || uri == null || fields == null) {

View file

@ -6,14 +6,14 @@ import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.Rect
import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MathUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
@ -30,12 +30,7 @@ class RegionFetcher internal constructor(
) {
private var lastDecoderRef: LastDecoderRef? = null
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
private val multiTrackGlideOptions = RequestOptions()
.format(DecodeFormat.PREFER_ARGB_8888)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.skipMemoryCache(true)
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
suspend fun fetch(
uri: Uri,
@ -45,25 +40,27 @@ class RegionFetcher internal constructor(
regionRect: Rect,
imageWidth: Int,
imageHeight: Int,
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
result: MethodChannel.Result,
) {
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
val id = Pair(uri, pageId)
// use JPEG export for requested page
fetch(
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) },
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
}
var currentDecoderRef = lastDecoderRef
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
if (currentDecoderRef != null && currentDecoderRef.requestKey != requestKey) {
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)
return
}
currentDecoderRef = LastDecoderRef(uri, newDecoder)
currentDecoderRef = LastDecoderRef(requestKey, newDecoder)
}
val decoder = currentDecoderRef.decoder
lastDecoderRef = currentDecoderRef
@ -119,16 +116,35 @@ class RegionFetcher internal constructor(
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
}
} 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)
}
}
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)
.asBitmap()
.apply(multiTrackGlideOptions)
.load(MultiPageImage(context, sourceUri, mimeType, pageId))
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.submit()
try {
val bitmap = target.get()
val tempFile = StorageUtils.createTempFile(context).apply {
@ -143,7 +159,11 @@ class RegionFetcher internal constructor(
}
private data class LastDecoderRef(
val uri: Uri,
val requestKey: Pair<Uri, Int?>,
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.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.AvesAppGlideModule
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.getBytes
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.UriUtils.tryParseId
import io.flutter.plugin.common.MethodChannel
import androidx.core.net.toUri
class ThumbnailFetcher internal constructor(
private val context: Context,
@ -41,7 +40,7 @@ class ThumbnailFetcher internal constructor(
private val quality: Int,
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 height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
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)
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
.override(width, height)
val target = if (isVideo(mimeType)) {
if (isVideo(mimeType)) {
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
Glide.with(context)
}
val target = Glide.with(context)
.asBitmap()
.apply(options)
.load(VideoThumbnail(context, uri))
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
.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 {
var bitmap = target.get()

View file

@ -7,6 +7,7 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.channel.calls.AppAdapterHandler
@ -71,7 +72,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
}
private fun requestMediaFileAccess() {
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null }
val 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 }
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
error("requestMediaFileAccess-args", "missing arguments", null)
@ -190,7 +191,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
val intent = Intent(Intent.ACTION_EDIT)
.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) {
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.Looper
import android.util.Log
import androidx.core.net.toUri
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat
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.decoder.AvesAppGlideModule
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getBytes
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 uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
val uri = (arguments["uri"] as String?)?.toUri()
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
val rotationDegrees = arguments["rotationDegrees"] as Int
val isFlipped = arguments["isFlipped"] as Boolean
@ -130,18 +126,10 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
rotationDegrees: Int,
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)
.asBitmap()
.apply(glideOptions)
.load(model)
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
.submit()
try {
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)
}
} 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 {
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?) {
val target = Glide.with(context)
.asBitmap()
.apply(glideOptions)
.load(VideoThumbnail(context, uri))
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
.submit()
try {
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"
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
import android.app.Activity
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
import deckers.thibault.aves.model.AvesEntry
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
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) {
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
return

View file

@ -1,14 +1,21 @@
package deckers.thibault.aves.decoder
import android.content.Context
import android.net.Uri
import android.util.Log
import com.bumptech.glide.Glide
import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
import com.bumptech.glide.load.DecodeFormat
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.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
@GlideModule
@ -25,4 +32,26 @@ class AvesAppGlideModule : AppGlideModule() {
}
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
import android.util.Log
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import com.drew.lang.Rational
import com.drew.metadata.Directory
import com.drew.metadata.exif.ExifDirectoryBase
@ -19,6 +18,7 @@ import java.util.Locale
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.roundToLong
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
object ExifInterfaceHelper {
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()

View file

@ -111,20 +111,25 @@ object MediaMetadataRetrieverHelper {
// format
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
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_BITRATE -> {
val bitrate = value.toLongOrNull() ?: 0
if (bitrate > 0) formatBitrate(bitrate) else null
}
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
val framerate = value.toDoubleOrNull() ?: 0.0
if (framerate > 0.0) "$framerate" else null
}
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
val dateMillis = value.toLongOrNull() ?: 0
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
}
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_RANGE_FULL -> "Full"
@ -132,6 +137,7 @@ object MediaMetadataRetrieverHelper {
else -> value
}
}
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
@ -141,6 +147,7 @@ object MediaMetadataRetrieverHelper {
else -> value
}
}
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
@ -154,6 +161,7 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
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_DATE -> {
val dateMillis = Metadata.parseVideoMetadataDate(value)
@ -168,4 +176,12 @@ object MediaMetadataRetrieverHelper {
}?.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.toByteArray
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.UserBox
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.AppleVariableSignedIntegerBox
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.support.AbstractBox
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.xmp.XmpDirectory
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.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
@ -47,14 +49,6 @@ object MultiPage {
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
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 extractor = MediaExtractor()
extractor.setDataSource(context, uri, null)
@ -250,23 +244,9 @@ object MultiPage {
}
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 extractor = MediaExtractor()
var pfd: ParcelFileDescriptor? = null
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)
getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
getTrailerVideoInfo(context, uri, fileSizeBytes = sizeBytes, videoSizeBytes = videoSizeBytes)?.let { videoInfo ->
// set the original image as the first and default track
var pageIndex = 0
pages.add(
@ -277,43 +257,28 @@ object MultiPage {
)
)
// add video tracks from the appended video
if (extractor.trackCount > 0) {
// 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 ->
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
if (MimeTypes.isVideo(mime)) {
val page: FieldMap = hashMapOf(
KEY_PAGE to pageIndex++,
KEY_MIME_TYPE to MimeTypes.MP4,
KEY_IS_DEFAULT to false,
)
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
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)
}
}
} 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
}
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)) {
// 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),
@ -360,6 +325,34 @@ object MultiPage {
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 toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
return hashMapOf(

View file

@ -26,7 +26,6 @@ import pixy.meta.string.XMLUtils
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import java.util.*
object PixyMetaHelper {
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 java.math.BigInteger
import java.nio.charset.Charset
import java.util.*
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)

View file

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

View file

@ -1,19 +1,20 @@
package deckers.thibault.aves.model
import android.net.Uri
import androidx.core.net.toUri
class AvesEntry(map: FieldMap) {
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
val path = map["path"] as String? // best effort to get local path
val pageId = map["pageId"] as Int? // null means the main entry
val mimeType = map["mimeType"] as String
val width = map["width"] as Int
val height = map["height"] as Int
val rotationDegrees = map["rotationDegrees"] as Int
val isFlipped = map["isFlipped"] as Boolean
val sizeBytes = toLong(map["sizeBytes"])
val trashed = map["trashed"] as Boolean
val trashPath = map["trashPath"] as String?
val uri: Uri = (map[EntryFields.URI] as String).toUri() // content or file URI
val path = map[EntryFields.PATH] as String? // best effort to get local path
val pageId = map[EntryFields.PAGE_ID] as Int? // null means the main entry
val mimeType = map[EntryFields.MIME_TYPE] as String
val width = map[EntryFields.WIDTH] as Int
val height = map[EntryFields.HEIGHT] as Int
val rotationDegrees = map[EntryFields.ROTATION_DEGREES] as Int
val isFlipped = map[EntryFields.IS_FLIPPED] as Boolean
val sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
val trashed = map[EntryFields.TRASHED] as Boolean
val trashPath = map[EntryFields.TRASH_PATH] as String?
private val isRotated: Boolean
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 java.io.IOException
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
import androidx.core.net.toUri
class SourceEntry {
private val origin: Int
@ -54,19 +55,19 @@ class SourceEntry {
}
constructor(map: FieldMap) {
origin = map["origin"] as Int
uri = Uri.parse(map["uri"] as String)
path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String
width = map["width"] as Int?
height = map["height"] as Int?
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
sizeBytes = toLong(map["sizeBytes"])
title = map["title"] as String?
dateAddedSecs = toLong(map["dateAddedSecs"])
dateModifiedSecs = toLong(map["dateModifiedSecs"])
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"])
durationMillis = toLong(map["durationMillis"])
origin = map[EntryFields.ORIGIN] as Int
uri = (map[EntryFields.URI] as String).toUri()
path = map[EntryFields.PATH] as String?
sourceMimeType = map[EntryFields.SOURCE_MIME_TYPE] as String
width = map[EntryFields.WIDTH] as Int?
height = map[EntryFields.HEIGHT] as Int?
sourceRotationDegrees = map[EntryFields.SOURCE_ROTATION_DEGREES] as Int?
sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
title = map[EntryFields.TITLE] as String?
dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
dateModifiedSecs = toLong(map[EntryFields.DATE_MODIFIED_SECS])
sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
}
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
@ -78,21 +79,21 @@ class SourceEntry {
fun toMap(): FieldMap {
return hashMapOf(
"origin" to origin,
"uri" to uri.toString(),
"path" to path,
"sourceMimeType" to sourceMimeType,
"width" to width,
"height" to height,
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
"sizeBytes" to sizeBytes,
"title" to title,
"dateAddedSecs" to dateAddedSecs,
"dateModifiedSecs" to dateModifiedSecs,
"sourceDateTakenMillis" to sourceDateTakenMillis,
"durationMillis" to durationMillis,
EntryFields.ORIGIN to origin,
EntryFields.URI to uri.toString(),
EntryFields.PATH to path,
EntryFields.SOURCE_MIME_TYPE to sourceMimeType,
EntryFields.WIDTH to width,
EntryFields.HEIGHT to height,
EntryFields.SOURCE_ROTATION_DEGREES to (sourceRotationDegrees ?: 0),
EntryFields.SIZE_BYTES to sizeBytes,
EntryFields.TITLE to title,
EntryFields.DATE_ADDED_SECS to dateAddedSecs,
EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
EntryFields.DURATION_MILLIS to durationMillis,
// 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.util.Log
import android.webkit.MimeTypeMap
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@ -88,9 +89,9 @@ internal class FileImageProvider : ImageProvider() {
}
return hashMapOf(
"uri" to Uri.fromFile(newFile).toString(),
"path" to newFile.path,
"dateModifiedSecs" to newFile.lastModified() / 1000,
EntryFields.URI to Uri.fromFile(newFile).toString(),
EntryFields.PATH to newFile.path,
EntryFields.DATE_MODIFIED_SECS to newFile.lastModified() / 1000,
)
}
@ -98,8 +99,8 @@ internal class FileImageProvider : ImageProvider() {
try {
val file = File(path)
if (file.exists()) {
newFields["dateModifiedSecs"] = file.lastModified() / 1000
newFields["sizeBytes"] = file.length()
newFields[EntryFields.DATE_MODIFIED_SECS] = file.lastModified() / 1000
newFields[EntryFields.SIZE_BYTES] = file.length()
}
callback.onSuccess(newFields)
} catch (e: SecurityException) {

View file

@ -11,16 +11,10 @@ import android.net.Uri
import android.os.Binder
import android.os.Build
import android.util.Log
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
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.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.metadata.ExifInterfaceHelper
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
@ -68,6 +62,9 @@ import java.nio.channels.Channels
import java.util.Date
import java.util.TimeZone
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 {
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)) {
val uri = Uri.fromFile(File(path))
hashMapOf(
"origin" to SourceEntry.ORIGIN_VAULT,
"uri" to uri.toString(),
"contentId" to null,
"path" to path,
EntryFields.ORIGIN to SourceEntry.ORIGIN_VAULT,
EntryFields.URI to uri.toString(),
EntryFields.CONTENT_ID to null,
EntryFields.PATH to path,
)
} else {
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)
.asBitmap()
.apply(glideOptions)
.load(model)
.apply(AvesAppGlideModule.uncachedFullImageOptions)
.load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
.submit(targetWidthPx, targetHeightPx)
var bitmap = withContext(Dispatchers.IO) { target.get() }
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
@ -380,7 +362,7 @@ abstract class ImageProvider {
)
val newFields = scanNewPath(activity, targetPath, exportMimeType)
val targetUri = Uri.parse(newFields["uri"] as String)
val targetUri = (newFields[EntryFields.URI] as String).toUri()
if (writeMetadata) {
copyMetadata(
context = activity,
@ -664,19 +646,21 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
var videoBytes: ByteArray? = null
var trailerVideoBytes: ByteArray? = null
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 {
if (videoSize != null) {
if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize)
val videoByteSize = videoSize.toInt()
trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(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
// video will be appended after metadata modification
@ -711,15 +695,15 @@ abstract class ImageProvider {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
}
if (videoBytes != null) {
if (trailerVideoBytes != null) {
// append trailer video, if any
editableFile.appendBytes(videoBytes!!)
editableFile.appendBytes(trailerVideoBytes!!)
}
// copy the edited temporary file back to the original
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
}
editableFile.delete()
@ -747,19 +731,21 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
var videoBytes: ByteArray? = null
var trailerVideoBytes: ByteArray? = null
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 {
if (videoSize != null) {
if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt()
videoBytes = ByteArray(videoSize)
val videoByteSize = videoSize.toInt()
trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(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
// 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
editableFile.appendBytes(videoBytes!!)
editableFile.appendBytes(trailerVideoBytes!!)
}
// copy the edited temporary file back to the original
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
}
editableFile.delete()
@ -913,7 +899,7 @@ abstract class ImageProvider {
}
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 {
try {
editXmpWithPixy(
@ -996,7 +982,7 @@ abstract class ImageProvider {
path: String,
uri: Uri,
mimeType: String,
trailerOffset: Int?,
trailerOffset: Number?,
editedFile: File,
callback: ImageOpCallback,
): Boolean {
@ -1011,7 +997,7 @@ abstract class ImageProvider {
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."
)
val newTrailerOffset = trailerOffset + diff
val newTrailerOffset = trailerOffset.toLong() + diff
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
})
@ -1276,12 +1262,18 @@ abstract class ImageProvider {
callback: ImageOpCallback,
) {
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) {
callback.onFailure(Exception("failed to get trailer video size"))
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 {
try {
val inputStream = StorageUtils.openInputStream(context, uri)
@ -1321,7 +1313,8 @@ abstract class ImageProvider {
}
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 {
try {
outputStream().use { output ->
@ -1341,7 +1334,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
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
}
editableFile.delete()

View file

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

View file

@ -7,6 +7,7 @@ import android.provider.OpenableColumns
import android.util.Log
import deckers.thibault.aves.metadata.Metadata
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.model.EntryFields
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
@ -43,9 +44,9 @@ open class UnknownContentProvider : ImageProvider() {
}
val fields: FieldMap = hashMapOf(
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
"uri" to uri.toString(),
"sourceMimeType" to mimeType,
EntryFields.ORIGIN to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
EntryFields.URI to uri.toString(),
EntryFields.SOURCE_MIME_TYPE to mimeType,
)
try {
// 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
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields["path"] = 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[EntryFields.SIZE_BYTES] = cursor.getLong(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`
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()
}
} catch (e: Exception) {
@ -65,7 +66,7 @@ open class UnknownContentProvider : ImageProvider() {
return
}
if (fields["sourceMimeType"] == null) {
if (fields[EntryFields.SOURCE_MIME_TYPE] == null) {
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
return
}

View file

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

View file

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

View file

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

View file

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

View file

@ -13,8 +13,8 @@ buildscript {
dependencies {
if (useCrashlytics) {
// GMS & Firebase Crashlytics (used by some flavors only)
classpath 'com.google.gms:google-services:4.4.1'
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9'
classpath 'com.google.gms:google-services:4.4.2'
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.nio.ByteOrder.BIG_ENDIAN;
import static java.nio.ByteOrder.LITTLE_ENDIAN;
import static java.nio.charset.StandardCharsets.UTF_8;
import android.annotation.SuppressLint;
import android.content.res.AssetManager;
@ -54,6 +55,7 @@ import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInput;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileDescriptor;
@ -89,8 +91,9 @@ import java.util.regex.Pattern;
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.
* 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
*/
@ -190,6 +193,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #DATA_UNCOMPRESSED
* @see #DATA_JPEG
*/
@ -205,6 +209,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #PHOTOMETRIC_INTERPRETATION_RGB
* @see #PHOTOMETRIC_INTERPRETATION_YCBCR
*/
@ -219,6 +224,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #ORIENTATION_NORMAL}</li>
* </ul>
* <p>
*
* @see #ORIENTATION_UNDEFINED
* @see #ORIENTATION_NORMAL
* @see #ORIENTATION_FLIP_HORIZONTAL
@ -254,6 +260,7 @@ public class ExifInterfaceFork {
* <li>Count = 1</li>
* </ul>
* <p>
*
* @see #FORMAT_CHUNKY
* @see #FORMAT_PLANAR
*/
@ -294,6 +301,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li>
* </ul>
* <p>
*
* @see #Y_CB_CR_POSITIONING_CENTERED
* @see #Y_CB_CR_POSITIONING_CO_SITED
*/
@ -309,6 +317,7 @@ public class ExifInterfaceFork {
* <li>Default = 72</li>
* </ul>
* <p>
*
* @see #TAG_Y_RESOLUTION
* @see #TAG_RESOLUTION_UNIT
*/
@ -324,6 +333,7 @@ public class ExifInterfaceFork {
* <li>Default = 72</li>
* </ul>
* <p>
*
* @see #TAG_X_RESOLUTION
* @see #TAG_RESOLUTION_UNIT
*/
@ -340,6 +350,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
* </ul>
* <p>
*
* @see #RESOLUTION_UNIT_INCHES
* @see #RESOLUTION_UNIT_CENTIMETERS
* @see #TAG_X_RESOLUTION
@ -365,6 +376,7 @@ public class ExifInterfaceFork {
* <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1)
* / {@link #TAG_ROWS_PER_STRIP})</p>
* <p>
*
* @see #TAG_ROWS_PER_STRIP
* @see #TAG_STRIP_BYTE_COUNTS
*/
@ -381,6 +393,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #TAG_STRIP_OFFSETS
* @see #TAG_STRIP_BYTE_COUNTS
*/
@ -656,6 +669,7 @@ public class ExifInterfaceFork {
* <li>Count = 1</li>
* </ul>
* <p>
*
* @see #COLOR_SPACE_S_RGB
* @see #COLOR_SPACE_UNCALIBRATED
*/
@ -962,6 +976,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li>
* </ul>
* <p>
*
* @see #EXPOSURE_PROGRAM_NOT_DEFINED
* @see #EXPOSURE_PROGRAM_MANUAL
* @see #EXPOSURE_PROGRAM_NORMAL
@ -1031,6 +1046,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #SENSITIVITY_TYPE_UNKNOWN
* @see #SENSITIVITY_TYPE_SOS
* @see #SENSITIVITY_TYPE_REI
@ -1197,6 +1213,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #METERING_MODE_UNKNOWN}</li>
* </ul>
* <p>
*
* @see #METERING_MODE_UNKNOWN
* @see #METERING_MODE_AVERAGE
* @see #METERING_MODE_CENTER_WEIGHT_AVERAGE
@ -1217,6 +1234,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li>
* </ul>
* <p>
*
* @see #LIGHT_SOURCE_UNKNOWN
* @see #LIGHT_SOURCE_DAYLIGHT
* @see #LIGHT_SOURCE_FLUORESCENT
@ -1253,6 +1271,7 @@ public class ExifInterfaceFork {
* <li>Count = 1</li>
* </ul>
* <p>
*
* @see #FLAG_FLASH_FIRED
* @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED
* @see #FLAG_FLASH_RETURN_LIGHT_DETECTED
@ -1365,6 +1384,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
* </ul>
* <p>
*
* @see #TAG_RESOLUTION_UNIT
* @see #RESOLUTION_UNIT_INCHES
* @see #RESOLUTION_UNIT_CENTIMETERS
@ -1407,6 +1427,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #SENSOR_TYPE_NOT_DEFINED
* @see #SENSOR_TYPE_ONE_CHIP
* @see #SENSOR_TYPE_TWO_CHIP
@ -1427,6 +1448,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #FILE_SOURCE_DSC}</li>
* </ul>
* <p>
*
* @see #FILE_SOURCE_OTHER
* @see #FILE_SOURCE_TRANSPARENT_SCANNER
* @see #FILE_SOURCE_REFLEX_SCANNER
@ -1444,6 +1466,7 @@ public class ExifInterfaceFork {
* <li>Default = 1</li>
* </ul>
* <p>
*
* @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED
*/
public static final String TAG_SCENE_TYPE = "SceneType";
@ -1457,6 +1480,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #TAG_SENSING_METHOD
* @see #SENSOR_TYPE_ONE_CHIP
*/
@ -1473,6 +1497,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li>
* </ul>
* <p>
*
* @see #RENDERED_PROCESS_NORMAL
* @see #RENDERED_PROCESS_CUSTOM
*/
@ -1489,6 +1514,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #EXPOSURE_MODE_AUTO
* @see #EXPOSURE_MODE_MANUAL
* @see #EXPOSURE_MODE_AUTO_BRACKET
@ -1504,6 +1530,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #WHITEBALANCE_AUTO
* @see #WHITEBALANCE_MANUAL
*/
@ -1553,6 +1580,7 @@ public class ExifInterfaceFork {
* <li>Default = 0</li>
* </ul>
* <p>
*
* @see #SCENE_CAPTURE_TYPE_STANDARD
* @see #SCENE_CAPTURE_TYPE_LANDSCAPE
* @see #SCENE_CAPTURE_TYPE_PORTRAIT
@ -1569,6 +1597,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #GAIN_CONTROL_NONE
* @see #GAIN_CONTROL_LOW_GAIN_UP
* @see #GAIN_CONTROL_HIGH_GAIN_UP
@ -1587,6 +1616,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #CONTRAST_NORMAL}</li>
* </ul>
* <p>
*
* @see #CONTRAST_NORMAL
* @see #CONTRAST_SOFT
* @see #CONTRAST_HARD
@ -1603,6 +1633,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #SATURATION_NORMAL}</li>
* </ul>
* <p>
*
* @see #SATURATION_NORMAL
* @see #SATURATION_LOW
* @see #SATURATION_HIGH
@ -1619,6 +1650,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #SHARPNESS_NORMAL}</li>
* </ul>
* <p>
*
* @see #SHARPNESS_NORMAL
* @see #SHARPNESS_SOFT
* @see #SHARPNESS_HARD
@ -1646,6 +1678,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #SUBJECT_DISTANCE_RANGE_UNKNOWN
* @see #SUBJECT_DISTANCE_RANGE_MACRO
* @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW
@ -1675,6 +1708,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead.
*/
@Deprecated
@ -1780,6 +1814,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #LATITUDE_NORTH
* @see #LATITUDE_SOUTH
*/
@ -1809,6 +1844,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #LONGITUDE_EAST
* @see #LONGITUDE_WEST
*/
@ -1841,6 +1877,7 @@ public class ExifInterfaceFork {
* <li>Default = 0</li>
* </ul>
* <p>
*
* @see #ALTITUDE_ABOVE_SEA_LEVEL
* @see #ALTITUDE_BELOW_SEA_LEVEL
*/
@ -1899,6 +1936,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #GPS_MEASUREMENT_IN_PROGRESS
* @see #GPS_MEASUREMENT_INTERRUPTED
*/
@ -1915,6 +1953,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #GPS_MEASUREMENT_2D
* @see #GPS_MEASUREMENT_3D
*/
@ -1941,6 +1980,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li>
* </ul>
* <p>
*
* @see #GPS_SPEED_KILOMETERS_PER_HOUR
* @see #GPS_SPEED_MILES_PER_HOUR
* @see #GPS_SPEED_KNOTS
@ -1968,6 +2008,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
* </ul>
* <p>
*
* @see #GPS_DIRECTION_TRUE
* @see #GPS_DIRECTION_MAGNETIC
*/
@ -1994,6 +2035,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
* </ul>
* <p>
*
* @see #GPS_DIRECTION_TRUE
* @see #GPS_DIRECTION_MAGNETIC
*/
@ -2032,6 +2074,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #LATITUDE_NORTH
* @see #LATITUDE_SOUTH
*/
@ -2061,6 +2104,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #LONGITUDE_EAST
* @see #LONGITUDE_WEST
*/
@ -2090,6 +2134,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
* </ul>
* <p>
*
* @see #GPS_DIRECTION_TRUE
* @see #GPS_DIRECTION_MAGNETIC
*/
@ -2116,6 +2161,7 @@ public class ExifInterfaceFork {
* <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li>
* </ul>
* <p>
*
* @see #GPS_DISTANCE_KILOMETERS
* @see #GPS_DISTANCE_MILES
* @see #GPS_DISTANCE_NAUTICAL_MILES
@ -2177,6 +2223,7 @@ public class ExifInterfaceFork {
* <li>Default = None</li>
* </ul>
* <p>
*
* @see #GPS_MEASUREMENT_NO_DIFFERENTIAL
* @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED
*/
@ -3132,11 +3179,18 @@ public class ExifInterfaceFork {
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
// 3.7. eXIf Exchangeable Image File (Exif) Profile
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_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;
/**
* 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"
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'};
@ -4069,20 +4123,33 @@ public class ExifInterfaceFork {
// Used to indicate offset from the start of the original input stream to EXIF data
private int mOffsetToExifData;
private int mOrfMakerNoteOffset;
/**
* The position of the thumbnail within the Exif data (from {@link #mOffsetToExifData}).
*/
private int mOrfThumbnailOffset;
private int mOrfThumbnailLength;
private boolean mModified;
/**
* 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
* TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a separate section is
* here. If both are present, the disambiguation rules vary per file format, see
* {@link #getXmpHandlingForImageType(int)}.
* section of the file (e.g. a separate APP1 segment in JPEG, or an iTXt chunk in PNG). XMP read
* from within the TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a
* separate section is here. If both are present, the disambiguation rules vary per file format,
* see {@link #getXmpHandlingForImageType(int)}.
*/
@Nullable
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
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
// Pattern to check gps timestamp
@ -4300,6 +4367,7 @@ public class ExifInterfaceFork {
return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
case IMAGE_TYPE_AVIF:
case IMAGE_TYPE_HEIC:
case IMAGE_TYPE_PNG:
// 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.
case IMAGE_TYPE_RAF:
@ -4309,10 +4377,8 @@ public class ExifInterfaceFork {
case IMAGE_TYPE_PEF:
case IMAGE_TYPE_RW2:
case IMAGE_TYPE_UNKNOWN:
// PNG and WebP support a separate XMP chunk (so should be
// XMP_HANDLING_PREFER_SEPARATE), but ExifInterface doesn't currently read or write
// them.
case IMAGE_TYPE_PNG:
// WebP supports a separate XMP chunk (so should be XMP_HANDLING_PREFER_SEPARATE), but
// ExifInterface doesn't currently read or write it.
case IMAGE_TYPE_WEBP:
default:
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,
* or {@code null} if the tag is not contained.
* Returns the offset and length of the requested tag inside the image file, or {@code null} if
* the tag is not contained.
*
* @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.
* <p>If the attribute has been modified with {@link #setAttribute(String, String)} but not yet
* written to disk with {@link #saveAttributes()}, the returned range will have the correct
* length for the modified value, but an offset of {@code -1} to indicate its position in the
* file isn't known.
*
* @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) {
if (tag == null) {
@ -5841,6 +5911,7 @@ public class ExifInterfaceFork {
IDENTIFIER_XMP_APP1.length, bytes.length);
mXmpFromSeparateMarker =
new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
mFileOnDiskContainsSeparateXmpMarker = true;
}
break;
}
@ -6165,6 +6236,7 @@ public class ExifInterfaceFork {
in.readFully(xmpBytes);
mXmpFromSeparateMarker =
new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
mFileOnDiskContainsSeparateXmpMarker = true;
}
if (DEBUG) {
@ -6352,10 +6424,12 @@ public class ExifInterfaceFork {
// See PNG (Portable Network Graphics) Specification, Version 1.2,
// 3.2. Chunk layout
try {
while (true) {
boolean foundExif = false;
boolean foundXmpItxt = false;
while (!foundExif || !foundXmpItxt) {
int length = in.readInt();
int type = in.readInt();
int startOfNextChunk = in.position() + length + PNG_CHUNK_CRC_BYTE_LENGTH;
// The first chunk must be the IHDR chunk
if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) {
@ -6367,7 +6441,7 @@ public class ExifInterfaceFork {
if (type == PNG_CHUNK_TYPE_IEND) {
// IEND marks the end of the image.
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.
mOffsetToExifData = in.position() - startPosition;
@ -6388,20 +6462,40 @@ public class ExifInterfaceFork {
updateCrcWithInt(crc, type);
crc.update(data);
if ((int) crc.getValue() != dataCrcValue) {
throw new IOException("Encountered invalid CRC value for PNG-EXIF chunk."
+ "\n recorded CRC value: " + dataCrcValue + ", calculated CRC "
+ "value: " + crc.getValue());
throw new IOException(
"Encountered invalid CRC value for PNG-EXIF chunk."
+ "\n recorded CRC value: "
+ dataCrcValue
+ ", calculated CRC "
+ "value: "
+ crc.getValue());
}
readExifSegment(data, IFD_TYPE_PRIMARY);
validateImages();
setThumbnailData(new ByteOrderedDataInputStream(data));
break;
} else {
foundExif = true;
} 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
in.skipFully(length + PNG_CHUNK_CRC_BYTE_LENGTH);
}
in.skipFully(startOfNextChunk - in.position());
}
mFileOnDiskContainsSeparateXmpMarker = foundXmpItxt;
} catch (EOFException e) {
// Should not reach here. Will only reach here if the file is corrupted or
// does not follow the PNG specifications
@ -6464,9 +6558,8 @@ public class ExifInterfaceFork {
// Exif data in WebP images (e.g.
// https://github.com/ImageMagick/ImageMagick/issues/3140)
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
int adjustedChunkSize = chunkSize - 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.
@ -6522,7 +6615,7 @@ public class ExifInterfaceFork {
// Write EXIF APP1 segment
dataOutputStream.writeByte(MARKER);
dataOutputStream.writeByte(MARKER_APP1);
writeExifSegment(dataOutputStream);
mOffsetToExifData = writeExifSegment(dataOutputStream);
if (mXmpFromSeparateMarker != null) {
// 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.write(IDENTIFIER_XMP_APP1);
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
mFileOnDiskContainsSeparateXmpMarker = true;
}
byte[] bytes = new byte[4096];
@ -6627,60 +6721,76 @@ public class ExifInterfaceFork {
// Copy PNG signature bytes
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
// EXIF chunk can appear anywhere between the first (IHDR) and last (IEND) chunks, except
// between IDAT chunks.
// Adhering to these rules,
// 1) if EXIF chunk did not exist in the original file, it will be stored right after the
// first chunk,
// 2) if EXIF chunk existed in the original file, it will be stored in the same location.
boolean needToWriteExif = true;
boolean needToWriteXmp = mXmpFromSeparateMarker != null;
while (needToWriteExif || needToWriteXmp) {
int chunkLength = dataInputStream.readInt();
int chunkType = dataInputStream.readInt();
if (chunkType == PNG_CHUNK_TYPE_IHDR) {
dataOutputStream.writeInt(chunkLength);
dataOutputStream.writeInt(chunkType);
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
if (mOffsetToExifData == 0) {
// Copy IHDR chunk bytes
int ihdrChunkLength = dataInputStream.readInt();
dataOutputStream.writeInt(ihdrChunkLength);
copy(dataInputStream, dataOutputStream, PNG_CHUNK_TYPE_BYTE_LENGTH
+ 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);
// There was no Exif segment in the original file, so we put it directly
// after the IHDR chunk.
writePngExifChunk(dataOutputStream);
needToWriteExif = false;
}
// Write EXIF data
ByteArrayOutputStream exifByteArrayOutputStream = null;
try {
// A byte array is needed to calculate the CRC value of this chunk which requires
// the chunk type bytes and the chunk data bytes.
exifByteArrayOutputStream = new ByteArrayOutputStream();
ByteOrderedDataOutputStream exifDataOutputStream =
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN);
// Store Exif data in separate byte array
writeExifSegment(exifDataOutputStream);
byte[] exifBytes =
((ByteArrayOutputStream) exifDataOutputStream.mOutputStream).toByteArray();
// Write EXIF chunk data
dataOutputStream.write(exifBytes);
// Write EXIF chunk CRC
CRC32 crc = new CRC32();
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
dataOutputStream.writeInt((int) crc.getValue());
} finally {
closeQuietly(exifByteArrayOutputStream);
if (mXmpFromSeparateMarker != null && !mFileOnDiskContainsSeparateXmpMarker) {
writePngXmpItxtChunk(dataOutputStream);
needToWriteXmp = false;
}
continue;
} else if (chunkType == PNG_CHUNK_TYPE_EXIF && needToWriteExif) {
writePngExifChunk(dataOutputStream);
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
needToWriteExif = false;
continue;
} else if (chunkType == PNG_CHUNK_TYPE_ITXT && needToWriteXmp) {
writePngXmpItxtChunk(dataOutputStream);
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
needToWriteXmp = false;
continue;
}
dataOutputStream.writeInt(chunkLength);
dataOutputStream.writeInt(chunkType);
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
}
// Copy the rest of the file
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.
// The header is composed of:
// "RIFF" + File Size + "WEBP"
@ -6726,11 +6836,12 @@ public class ExifInterfaceFork {
// WebP signature
copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
// File length will be written after all the chunks have been written
totalInputStream.skipFully(WEBP_FILE_SIZE_BYTE_LENGTH + WEBP_SIGNATURE_2.length);
int riffLength = totalInputStream.readInt();
totalInputStream.skipFully(WEBP_SIGNATURE_2.length);
// Create a separate byte array to calculate file length
ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
int exifOffset = -1;
try {
nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
ByteOrderedDataOutputStream nonHeaderOutputStream =
@ -6756,7 +6867,7 @@ public class ExifInterfaceFork {
totalInputStream.skipFully(exifChunkLength);
// Write new EXIF chunk to output stream
writeExifSegment(nonHeaderOutputStream);
exifOffset = writeExifSegment(nonHeaderOutputStream);
} else {
// EXIF chunk does not exist in the original file
byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
@ -6801,7 +6912,7 @@ public class ExifInterfaceFork {
animationFinished = true;
}
if (animationFinished) {
writeExifSegment(nonHeaderOutputStream);
exifOffset = writeExifSegment(nonHeaderOutputStream);
break;
}
copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
@ -6810,7 +6921,7 @@ public class ExifInterfaceFork {
// Skip until we find the VP8 or VP8L chunk
copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
writeExifSegment(nonHeaderOutputStream);
exifOffset = writeExifSegment(nonHeaderOutputStream);
}
} else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
|| Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
@ -6897,18 +7008,24 @@ public class ExifInterfaceFork {
copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
// Write EXIF chunk
writeExifSegment(nonHeaderOutputStream);
exifOffset = writeExifSegment(nonHeaderOutputStream);
}
}
// Copy the rest of the file
copy(totalInputStream, nonHeaderOutputStream);
// Copy the rest of the RIFF part of the file
int remainingRiffBytes = riffLength + 8 - totalInputStream.position();
copy(totalInputStream, nonHeaderOutputStream, remainingRiffBytes);
// Write file length + second signature
totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
+ WEBP_SIGNATURE_2.length);
totalOutputStream.write(WEBP_SIGNATURE_2);
if (exifOffset != -1) {
mOffsetToExifData = totalOutputStream.mOutputStream.size() + exifOffset;
}
nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
// Copy any non-RIFF trailing data
copy(totalInputStream, totalOutputStream);
} catch (Exception e) {
throw new IOException("Failed to save WebP file", e);
} 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 {
// The following variables are for calculating each IFD tag group size in bytes.
int[] ifdOffsets = new int[EXIF_TAGS.length];
@ -7772,6 +7894,8 @@ public class ExifInterfaceFork {
break;
}
int offsetToExifData = dataOutputStream.mOutputStream.size();
// 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.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.
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
// order.
private static class ByteOrderedDataOutputStream extends FilterOutputStream {
final OutputStream mOutputStream;
final DataOutputStream mOutputStream;
private ByteOrder mByteOrder;
public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
super(out);
mOutputStream = out;
mOutputStream = new DataOutputStream(out);
mByteOrder = byteOrder;
}

View file

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

View file

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
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.kotlin_version = '1.9.24'
settings.ext.ksp_version = "$kotlin_version-1.0.20"
settings.ext.agp_version = '8.7.0'
settings.ext.kotlin_version = '2.1.10'
settings.ext.ksp_version = "$kotlin_version-1.0.29"
settings.ext.agp_version = '8.8.0'
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.
<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,
pickUnfilteredMediaInternal,
pickFilterInternal,
previewMap,
screenSaver,
setWallpaper,
slideshow,

View file

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

View file

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

View file

@ -508,14 +508,17 @@
"editEntryLocationDialogTitle": "Location",
"editEntryLocationDialogSetCustom": "Set custom location",
"editEntryLocationDialogChooseOnMap": "Choose on map",
"editEntryLocationDialogImportGpx": "Import GPX",
"editEntryLocationDialogLatitude": "Latitude",
"editEntryLocationDialogLongitude": "Longitude",
"editEntryLocationDialogTimeShift": "Time shift",
"locationPickerUseThisLocationButton": "Use this location",
"editEntryRatingDialogTitle": "Rating",
"removeEntryMetadataDialogTitle": "Metadata Removal",
"removeEntryMetadataDialogAll": "All",
"removeEntryMetadataDialogMore": "More",
"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": {},
"chipActionRename": "Muuda nime",
"@chipActionRename": {},
"chipActionShowCountryStates": "Näita riike",
"chipActionShowCountryStates": "Näita osariike",
"@chipActionShowCountryStates": {},
"chipActionCreateAlbum": "Loo album",
"@chipActionCreateAlbum": {},
@ -326,7 +326,7 @@
"@filterTypeMotionPhotoLabel": {},
"filterTypePanoramaLabel": "Panoraam",
"@filterTypePanoramaLabel": {},
"filterTypeRawLabel": "Töötlemata raw-vormingus foto",
"filterTypeRawLabel": "Raw-vorming",
"@filterTypeRawLabel": {},
"filterTypeSphericalVideoLabel": "360° video",
"@filterTypeSphericalVideoLabel": {},
@ -963,7 +963,7 @@
"@drawerCollectionMotionPhotos": {},
"drawerCollectionPanoramas": "Panoraamfotod",
"@drawerCollectionPanoramas": {},
"drawerCollectionRaws": "Töötlemata fotod",
"drawerCollectionRaws": "Raw-vormingus fotod",
"@drawerCollectionRaws": {},
"drawerCollectionSphericalVideos": "360° videod",
"@drawerCollectionSphericalVideos": {},
@ -1211,7 +1211,7 @@
"@settingsThumbnailShowMotionPhotoIcon": {},
"settingsThumbnailShowRating": "Näita hinnangute ikooni",
"@settingsThumbnailShowRating": {},
"settingsThumbnailShowRawIcon": "Näita töötlemata fotode ikooni",
"settingsThumbnailShowRawIcon": "Näita Raw-vormingu ikooni",
"@settingsThumbnailShowRawIcon": {},
"settingsThumbnailShowVideoDuration": "Näita videote kestust",
"@settingsThumbnailShowVideoDuration": {},
@ -1604,5 +1604,13 @@
"viewerInfoViewXmlLinkText": "Vaata XMLi",
"@viewerInfoViewXmlLinkText": {},
"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": {},
"storageVolumeDescriptionFallbackNonPrimary": "SD-kortti",
"@storageVolumeDescriptionFallbackNonPrimary": {}
"@storageVolumeDescriptionFallbackNonPrimary": {},
"aboutCreditsSectionTitle": "Kiitettävää",
"@aboutCreditsSectionTitle": {}
}

View file

@ -1406,5 +1406,13 @@
"collectionActionAddDynamicAlbum": "Ajouter un album dynamique",
"@collectionActionAddDynamicAlbum": {},
"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": {},
"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": {},
"chipActionDecompose": "Pisah",
"@chipActionDecompose": {}
"@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Impor GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Pergeseran waktu",
"@editEntryLocationDialogTimeShift": {}
}

View file

@ -1420,7 +1420,7 @@
"@authenticateToUnlockVault": {},
"chipActionConfigureVault": "Stilla öryggisgeymslu",
"@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": {},
"keepScreenOnViewerOnly": "Aðeins síða skoðara",
"@keepScreenOnViewerOnly": {},
@ -1520,5 +1520,55 @@
"chipActionShowCollection": "Sýna í safni",
"@chipActionShowCollection": {},
"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": {},
"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": {},
"filterMimeVideoLabel": "동영상",
"@filterMimeVideoLabel": {},
"coordinateFormatDms": "도초",
"coordinateFormatDms": "도, 분, 초",
"@coordinateFormatDms": {},
"coordinateFormatDecimal": "소수점",
"coordinateFormatDecimal": "십신 도",
"@coordinateFormatDecimal": {},
"coordinateDms": "{direction} {coordinate}",
"@coordinateDms": {},
@ -1406,5 +1406,13 @@
"appExportDynamicAlbums": "동적 앨범",
"@appExportDynamicAlbums": {},
"chipActionDecompose": "나누기",
"@chipActionDecompose": {}
"@chipActionDecompose": {},
"coordinateFormatDdm": "도, 십진 분",
"@coordinateFormatDdm": {},
"editEntryLocationDialogTimeShift": "시간 이동",
"@editEntryLocationDialogTimeShift": {},
"editEntryLocationDialogImportGpx": "GPX 가져오기",
"@editEntryLocationDialogImportGpx": {},
"removeEntryMetadataDialogAll": "모두",
"@removeEntryMetadataDialogAll": {}
}

View file

@ -1408,5 +1408,13 @@
"chipActionRemove": "Verwijderen",
"@chipActionRemove": {},
"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": {},
"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": {},
"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": {},
"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": {},
"appExportDynamicAlbums": "Динамічні альбоми",
"@appExportDynamicAlbums": {},
"chipActionDecompose": "Спліт",
"@chipActionDecompose": {}
"chipActionDecompose": "Розділити",
"@chipActionDecompose": {},
"coordinateFormatDdm": "DDM",
"@coordinateFormatDdm": {},
"editEntryLocationDialogImportGpx": "Імпорт GPX",
"@editEntryLocationDialogImportGpx": {},
"editEntryLocationDialogTimeShift": "Зсув часу",
"@editEntryLocationDialogTimeShift": {},
"removeEntryMetadataDialogAll": "Все",
"@removeEntryMetadataDialogAll": {}
}

View file

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

View file

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

View file

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

View file

@ -1,7 +1,5 @@
import 'package:aves/services/common/services.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:package_info_plus/package_info_plus.dart';
@ -57,13 +55,6 @@ class Device {
final auth = LocalAuthentication();
_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();
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
@ -74,5 +65,6 @@ class Device {
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? 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/dirs.dart';
import 'package:aves/model/entry/extensions/keys.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart';
@ -127,63 +128,63 @@ class AvesEntry with AvesEntryBase {
// from DB or platform source entry
factory AvesEntry.fromMap(Map map) {
return AvesEntry(
id: map['id'] as int?,
uri: map['uri'] as String,
path: map['path'] as String?,
id: map[EntryFields.id] as int?,
uri: map[EntryFields.uri] as String,
path: map[EntryFields.path] as String?,
pageId: null,
contentId: map['contentId'] as int?,
sourceMimeType: map['sourceMimeType'] as String,
width: map['width'] as int? ?? 0,
height: map['height'] as int? ?? 0,
sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
sizeBytes: map['sizeBytes'] as int?,
sourceTitle: map['title'] as String?,
dateAddedSecs: map['dateAddedSecs'] as int?,
dateModifiedSecs: map['dateModifiedSecs'] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
durationMillis: map['durationMillis'] as int?,
trashed: (map['trashed'] as int? ?? 0) != 0,
origin: map['origin'] as int,
contentId: map[EntryFields.contentId] as int?,
sourceMimeType: map[EntryFields.sourceMimeType] as String,
width: map[EntryFields.width] as int? ?? 0,
height: map[EntryFields.height] as int? ?? 0,
sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0,
sizeBytes: map[EntryFields.sizeBytes] as int?,
sourceTitle: map[EntryFields.title] as String?,
dateAddedSecs: map[EntryFields.dateAddedSecs] as int?,
dateModifiedSecs: map[EntryFields.dateModifiedSecs] as int?,
sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?,
durationMillis: map[EntryFields.durationMillis] as int?,
trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
origin: map[EntryFields.origin] as int,
);
}
// for DB only
Map<String, dynamic> toMap() {
return {
'id': id,
'uri': uri,
'path': path,
'contentId': contentId,
'sourceMimeType': sourceMimeType,
'width': width,
'height': height,
'sourceRotationDegrees': sourceRotationDegrees,
'sizeBytes': sizeBytes,
'title': sourceTitle,
'dateAddedSecs': dateAddedSecs,
'dateModifiedSecs': dateModifiedSecs,
'sourceDateTakenMillis': sourceDateTakenMillis,
'durationMillis': durationMillis,
'trashed': trashed ? 1 : 0,
'origin': origin,
EntryFields.id: id,
EntryFields.uri: uri,
EntryFields.path: path,
EntryFields.contentId: contentId,
EntryFields.sourceMimeType: sourceMimeType,
EntryFields.width: width,
EntryFields.height: height,
EntryFields.sourceRotationDegrees: sourceRotationDegrees,
EntryFields.sizeBytes: sizeBytes,
EntryFields.title: sourceTitle,
EntryFields.dateAddedSecs: dateAddedSecs,
EntryFields.dateModifiedSecs: dateModifiedSecs,
EntryFields.sourceDateTakenMillis: sourceDateTakenMillis,
EntryFields.durationMillis: durationMillis,
EntryFields.trashed: trashed ? 1 : 0,
EntryFields.origin: origin,
};
}
Map<String, dynamic> toPlatformEntryMap() {
return {
'uri': uri,
'path': path,
'pageId': pageId,
'mimeType': mimeType,
'width': width,
'height': height,
'rotationDegrees': rotationDegrees,
'isFlipped': isFlipped,
'dateModifiedSecs': dateModifiedSecs,
'sizeBytes': sizeBytes,
'trashed': trashed,
'trashPath': trashDetails?.path,
'origin': origin,
EntryFields.uri: uri,
EntryFields.path: path,
EntryFields.pageId: pageId,
EntryFields.mimeType: mimeType,
EntryFields.width: width,
EntryFields.height: height,
EntryFields.rotationDegrees: rotationDegrees,
EntryFields.isFlipped: isFlipped,
EntryFields.dateModifiedSecs: dateModifiedSecs,
EntryFields.sizeBytes: sizeBytes,
EntryFields.trashed: trashed,
EntryFields.trashPath: trashDetails?.path,
EntryFields.origin: origin,
};
}
@ -402,34 +403,34 @@ class AvesEntry with AvesEntryBase {
final oldRotationDegrees = this.rotationDegrees;
final oldIsFlipped = this.isFlipped;
final uri = newFields['uri'];
final uri = newFields[EntryFields.uri];
if (uri is String) this.uri = uri;
final path = newFields['path'];
final path = newFields[EntryFields.path];
if (path is String) this.path = path;
final contentId = newFields['contentId'];
final contentId = newFields[EntryFields.contentId];
if (contentId is int) this.contentId = contentId;
final sourceTitle = newFields['title'];
final sourceTitle = newFields[EntryFields.title];
if (sourceTitle is String) this.sourceTitle = sourceTitle;
final sourceRotationDegrees = newFields['sourceRotationDegrees'];
final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees];
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
final sourceDateTakenMillis = newFields['sourceDateTakenMillis'];
final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis];
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
final width = newFields['width'];
final width = newFields[EntryFields.width];
if (width is int) this.width = width;
final height = newFields['height'];
final height = newFields[EntryFields.height];
if (height is int) this.height = height;
final durationMillis = newFields['durationMillis'];
final durationMillis = newFields[EntryFields.durationMillis];
if (durationMillis is int) this.durationMillis = durationMillis;
final sizeBytes = newFields['sizeBytes'];
final sizeBytes = newFields[EntryFields.sizeBytes];
if (sizeBytes is int) this.sizeBytes = sizeBytes;
final dateModifiedSecs = newFields['dateModifiedSecs'];
final dateModifiedSecs = newFields[EntryFields.dateModifiedSecs];
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
final rotationDegrees = newFields['rotationDegrees'];
final rotationDegrees = newFields[EntryFields.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 (persist) {

View file

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

View file

@ -67,13 +67,13 @@ extension ExtraAvesEntryImages on AvesEntry {
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
}
// magic number used to derive sample size from scale
static const scaleFactor = 2.0;
static int sampleSizeForScale(double scale) {
static int sampleSizeForScale({
required double magnifierScale,
required double devicePixelRatio,
}) {
var sample = 0;
if (0 < scale && scale < 1) {
sample = highestPowerOf2((1 / scale) / scaleFactor);
if (0 < magnifierScale && magnifierScale < 1) {
sample = highestPowerOf2(1 / (magnifierScale * devicePixelRatio));
}
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 '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/codecs.dart';
import 'package:aves/model/media/video/profiles/aac.dart';
@ -46,6 +47,7 @@ class VideoMetadataFormatter {
Codecs.webm: 'WebM',
};
// fetch size, rotation and duration
static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async {
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
final fields = <String, int>{};
@ -58,25 +60,31 @@ class VideoMetadataFormatter {
final width = sizedStream[Keys.videoWidth];
final height = sizedStream[Keys.videoHeight];
if (width is int && height is int) {
fields['width'] = width;
fields['height'] = height;
fields[EntryFields.width] = width;
fields[EntryFields.height] = height;
}
final rotationDegrees = sizedStream[Keys.rotate];
if (rotationDegrees is int) {
fields[EntryFields.rotationDegrees] = rotationDegrees;
}
}
}
final durationMicros = mediaInfo[Keys.durationMicros];
if (durationMicros is num) {
fields['durationMillis'] = (durationMicros / 1000).round();
fields[EntryFields.durationMillis] = (durationMicros / 1000).round();
} else {
final duration = _parseDuration(mediaInfo[Keys.duration]);
if (duration != null && duration > Duration.zero) {
fields['durationMillis'] = duration.inMilliseconds;
fields[EntryFields.durationMillis] = duration.inMilliseconds;
}
}
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);
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/modules/app.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/filter_grids.dart';
import 'package:aves/model/settings/modules/info.dart';
@ -40,7 +41,7 @@ import 'package:latlong2/latlong.dart';
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 EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
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/entry/entry.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/sort.dart';
import 'package:aves/model/favourites.dart';
@ -242,29 +243,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
newFields.keys.forEach((key) {
final newValue = newFields[key];
switch (key) {
case 'contentId':
case EntryFields.contentId:
entry.contentId = newValue as int?;
case 'dateModifiedSecs':
case EntryFields.dateModifiedSecs:
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
entry.dateModifiedSecs = newValue as int?;
case 'path':
case EntryFields.path:
entry.path = newValue as String?;
case 'title':
case EntryFields.title:
entry.sourceTitle = newValue as String?;
case 'trashed':
case EntryFields.trashed:
final trashed = newValue as bool;
entry.trashed = trashed;
entry.trashDetails = trashed
? TrashDetails(
id: entry.id,
path: newFields['trashPath'] as String,
path: newFields[EntryFields.trashPath] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch,
)
: null;
case 'uri':
case EntryFields.uri:
entry.uri = newValue as String;
case 'origin':
case EntryFields.origin:
entry.origin = newValue as int;
}
});
@ -341,7 +342,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
if (movedOps.isEmpty) return;
final replacedUris = movedOps
.map((movedOp) => movedOp.newFields['path'] as String?)
.map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
.map((targetPath) {
final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed);
return existingEntry?.uri;
@ -362,14 +363,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry.copyWith(
id: localMediaDb.nextId,
uri: newFields['uri'] as String?,
path: newFields['path'] as String?,
contentId: newFields['contentId'] as int?,
uri: newFields[EntryFields.uri] as String?,
path: newFields[EntryFields.path] as String?,
contentId: newFields[EntryFields.contentId] as int?,
// title can change when moved files are automatically renamed to avoid conflict
title: newFields['title'] as String?,
dateAddedSecs: newFields['dateAddedSecs'] as int?,
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
origin: newFields['origin'] as int?,
title: newFields[EntryFields.title] as String?,
dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
dateModifiedSecs: newFields[EntryFields.dateModifiedSecs] as int?,
origin: newFields[EntryFields.origin] as int?,
));
} else {
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);
if (entry != null) {
if (moveType == MoveType.fromBin) {
newFields['trashed'] = false;
newFields[EntryFields.trashed] = false;
} else {
fromAlbums.add(entry.directory);
}

View file

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

View file

@ -9,6 +9,7 @@ extension ExtraLocationEditActionView on LocationEditAction {
LocationEditAction.chooseOnMap => l10n.editEntryLocationDialogChooseOnMap,
LocationEditAction.copyItem => l10n.editEntryDialogCopyFromItem,
LocationEditAction.setCustom => l10n.editEntryLocationDialogSetCustom,
LocationEditAction.importGpx => l10n.editEntryLocationDialogImportGpx,
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 packageInfo = await PackageInfo.fromPlatform();
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 storageVolumes = await storageService.getStorageVolumes();
final storageGrants = await storageService.getGrantedDirectories();
final supportsHdr = await windowService.supportsHdr();
return [
'Package: ${device.packageName}',
'Installer: ${packageInfo.installerStore}',
@ -166,6 +168,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
'Android version: ${androidInfo.version.release}, API ${androidInfo.version.sdkInt}',
'Android build: ${androidInfo.display}',
'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',
'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}',
'Connectivity: ${connections.map((v) => v.name).join(', ')}',

View file

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

View file

@ -292,26 +292,29 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final details = vaults.getVault(entry.directory);
return details?.useBin ?? settings.enableBin;
});
await Future.forEach(
byBinUsage.entries,
(kv) => doDelete(
var completed = true;
await Future.forEach(byBinUsage.entries, (kv) async {
completed &= await doDelete(
context: context,
entries: kv.value.toSet(),
enableBin: kv.key,
));
);
});
if (completed) {
_browse(context);
}
}
Future<void> doDelete({
// returns whether it completed the action (with or without failures)
Future<bool> doDelete({
required BuildContext context,
required Set<AvesEntry> entries,
required bool enableBin,
}) async {
final pureTrash = entries.every((entry) => entry.trashed);
if (enableBin && !pureTrash) {
await doMove(context, moveType: MoveType.toBin, entries: entries);
return;
return await doMove(context, moveType: MoveType.toBin, entries: entries);
}
final l10n = context.l10n;
@ -325,10 +328,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
message: l10n.deleteEntriesConfirmationDialogMessage(todoCount),
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();
final opId = mediaEditService.newOpId;
@ -338,9 +341,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
itemCount: todoCount,
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final deletedOps = successOps.where((e) => !e.skipped).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet();
final successOps = processed.where((op) => op.success).toSet();
final deletedOps = successOps.where((op) => !op.skipped).toSet();
final deletedUris = deletedOps.map((op) => op.uri).toSet();
await source.removeEntries(deletedUris, includeTrash: true);
source.resumeMonitoring();
@ -354,14 +357,17 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
await storageService.deleteEmptyRegularDirectories(storageDirs);
},
);
return true;
}
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
final entries = _getTargetItems(context);
await doMove(context, moveType: moveType, entries: entries);
final completed = await doMove(context, moveType: moveType, entries: entries);
if (completed) {
_browse(context);
}
}
Future<void> _rename(BuildContext context) async {
final entries = _getTargetItems(context).toList();
@ -381,10 +387,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
return MapEntry(entry, '$newName${entry.extension}');
});
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);
}
}
Future<void> _convert(BuildContext context) async {
final entries = _getTargetItems(context);
@ -398,13 +406,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
switch (options.action) {
case EntryConvertAction.convert:
await doExport(context, entries, options);
final completed = await doExport(context, entries, options);
if (completed) {
_browse(context);
}
case EntryConvertAction.convertMotionPhotoToStillImage:
final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet();
await _edit(context, todoItems, (entry) => entry.removeTrailerVideo());
}
_browse(context);
}
Future<void> _toggleFavourite(BuildContext context) async {
@ -451,11 +460,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
itemCount: todoCount,
onCancel: () => cancelled = true,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final editedOps = successOps.where((e) => !e.skipped).toSet();
final successOps = processed.where((op) => op.success).toSet();
final editedOps = successOps.where((op) => !op.skipped).toSet();
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
// this invalidation must happen after the source is refreshed,
// 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;
final collection = context.read<CollectionLens>();
final location = await selectLocation(context, entries, collection);
if (location == null) return;
final locationByEntry = await selectLocation(context, entries, collection);
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 {

View file

@ -1,9 +1,9 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/metadata_edition.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/placeholder.dart';
import 'package:aves/model/filters/covered/tag.dart';
import 'package:aves/model/metadata/date_modifier.dart';
import 'package:aves/model/source/collection_lens.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/tag_editor_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
mixin EntryEditorMixin {
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;
final entry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
return showDialog<LatLng>(
return showDialog<LocationEditActionResult>(
context: context,
builder: (context) => EditEntryLocationDialog(
entry: entry,
entries: entries,
collection: collection,
),
routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName),

View file

@ -3,8 +3,11 @@ import 'dart:io';
import 'package:aves/app_mode.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/props.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/covered/stored_album.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/highlight.dart';
@ -37,14 +40,15 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
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);
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return;
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return false;
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 selection = <AvesEntry>{};
@ -89,7 +93,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
),
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
);
if (value == null) return;
if (value == null) return false;
nameConflictStrategy = value;
}
@ -106,13 +110,28 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
),
itemCount: selectionCount,
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final exportedOps = successOps.where((e) => !e.skipped).toSet();
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).nonNulls.toSet();
final successOps = processed.where((op) => op.success).toSet();
final exportedOps = successOps.where((op) => !op.skipped && op.newFields[EntryFields.uri] != null).toSet();
final newUris = exportedOps.map((op) => op.newFields[EntryFields.uri] as String).toSet();
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();
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
// 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());
return true;
}
Future<void> doQuickMove(
// returns whether it completed the action (with or without failures)
Future<bool> doQuickMove(
BuildContext context, {
required MoveType moveType,
required Map<String, Iterable<AvesEntry>> entriesByDestination,
required Map<String, Set<AvesEntry>> entriesByDestination,
bool hideShowAction = false,
VoidCallback? onSuccess,
}) 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 todoCount = entries.length;
assert(todoCount > 0);
if (todoCount == 0) return true;
final toBin = moveType == MoveType.toBin;
final copy = moveType == MoveType.copy;
// permission for modification at destinations
final destinationAlbums = entriesByDestination.keys.toSet();
if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return;
if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return false;
// permission for modification at origins
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) {
return checkFreeSpaceForMove(context, entries, destinationAlbum, moveType);
}));
if (hasEnoughSpaceByDestination.any((v) => !v)) return;
if (hasEnoughSpaceByDestination.any((v) => !v)) return false;
final l10n = context.l10n;
var nameConflictStrategy = NameConflictStrategy.rename;
@ -209,12 +238,12 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
),
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
);
if (value == null) return;
if (value == null) return false;
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>();
source.pauseMonitoring();
@ -230,11 +259,11 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
itemCount: todoCount,
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((v) => v.success).toSet();
final successOps = processed.where((op) => op.success).toSet();
// move
final movedOps = successOps.where((v) => !v.skipped && !v.deleted).toSet();
final movedEntries = movedOps.map((v) => v.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).nonNulls.toSet();
final movedOps = successOps.where((op) => !op.skipped && !op.deleted).toSet();
final movedEntries = movedOps.map((op) => op.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).nonNulls.toSet();
await source.updateAfterMove(
todoEntries: entries,
moveType: moveType,
@ -243,8 +272,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
);
// delete (when trying to move to bin obsolete entries)
final deletedOps = successOps.where((v) => v.deleted).toSet();
final deletedUris = deletedOps.map((event) => event.uri).toSet();
final deletedOps = successOps.where((op) => op.deleted).toSet();
final deletedUris = deletedOps.map((op) => op.uri).toSet();
await source.removeEntries(deletedUris, includeTrash: true);
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, {
required MoveType moveType,
required Set<AvesEntry> entries,
@ -330,7 +361,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
message: l10n.binEntriesConfirmationDialogMessage(entries.length),
confirmationButtonLabel: l10n.deleteButtonLabel,
)) {
return;
return false;
}
}
@ -340,7 +371,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
case MoveType.move:
case MoveType.export:
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;
settings.recentDestinationAlbums = settings.recentDestinationAlbums
@ -357,7 +388,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
});
}
await doQuickMove(
return await doQuickMove(
context,
moveType: moveType,
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, {
required Map<AvesEntry, String> entriesToNewName,
required bool persist,
@ -375,9 +407,9 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final todoCount = entries.length;
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>();
source.pauseMonitoring();
@ -391,8 +423,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
itemCount: todoCount,
onCancel: () => mediaEditService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet();
final successOps = processed.where((op) => op.success).toSet();
final movedOps = successOps.where((op) => !op.skipped).toSet();
await source.updateAfterRename(
todoEntries: entries,
movedOps: movedOps,
@ -412,6 +444,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
}
},
);
return true;
}
Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async {
@ -451,7 +484,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Set<String> destinationAlbums,
Set<MoveOpEvent> movedOps,
) 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);
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;
// 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
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(
color: _borderColor(context),

View file

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

View file

@ -30,6 +30,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
final Size markerSize, dotMarkerSize;
final ValueNotifier<double>? overlayOpacityNotifier;
final MapOverlay? overlayEntry;
final Set<List<LatLng>>? tracks;
final UserZoomChangeCallback? onUserZoomChange;
final MapTapCallback? onMapTap;
final MarkerTapCallback<T>? onMarkerTap;
@ -52,6 +53,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
required this.dotMarkerSize,
this.overlayOpacityNotifier,
this.overlayEntry,
this.tracks,
this.onUserZoomChange,
this.onMapTap,
this.onMarkerTap,
@ -175,6 +177,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
children: [
_buildMapLayer(),
if (widget.overlayEntry != null) _buildOverlayImageLayer(),
if (widget.tracks != null) _buildTracksLayer(),
MarkerLayer(
markers: markers,
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 _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/services/analysis_service.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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:collection/collection.dart';
import 'package:flutter/material.dart';
@ -61,6 +63,11 @@ class _DebugGeneralSectionState extends State<DebugGeneralSection> with Automati
},
title: const Text('Show tasks overlay'),
),
SettingsSwitchListTile(
selector: (context, s) => s.debugShowViewerTiles,
onChanged: (v) => settings.debugShowViewerTiles = v,
title: 'Show viewer tiles',
),
ElevatedButton(
onPressed: () => LeakTracking.collectLeaks().then((leaks) {
const config = LeakDiagnosticConfig(

View file

@ -1,24 +1,21 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/metadata/date_modifier.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/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:aves/view/view.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/fx/transitions.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.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/pick_dialogs/item_pick_page.dart';
import 'package:aves_model/aves_model.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class EditEntryDateDialog extends StatefulWidget {
@ -42,17 +39,13 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
late AvesEntry _copyItemSource;
late DateTime _customDateTime;
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
late ValueNotifier<String> _shiftSign;
late TimeShiftController _timeShiftController;
bool _showOptions = false;
final Set<MetadataField> _fields = {...DateModifier.writableFields};
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
static const _positiveSign = '+';
static const _negativeSign = '-';
@override
void initState() {
super.initState();
@ -65,10 +58,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
@override
void dispose() {
_isValidNotifier.dispose();
_shiftHour.dispose();
_shiftMinute.dispose();
_shiftSecond.dispose();
_shiftSign.dispose();
super.dispose();
}
@ -81,10 +70,9 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
}
void _initShift() {
_shiftHour = ValueNotifier(1);
_shiftMinute = ValueNotifier(0);
_shiftSecond = ValueNotifier(0);
_shiftSign = ValueNotifier(_positiveSign);
_timeShiftController = TimeShiftController(
initialValue: const Duration(hours: 1),
);
}
@override
@ -203,80 +191,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
}
Widget _buildShiftContent(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,
),
);
return TimeShiftSelector(controller: _timeShiftController);
}
Widget _buildDestinationFields(BuildContext context) {
@ -368,7 +283,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
fullscreenDialog: true,
),
);
pickCollection.dispose();
if (entry != null) {
setState(() => _copyItemSource = entry);
}
@ -388,8 +302,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
case DateEditAction.extractFromTitle:
return DateModifier.extractFromTitle();
case DateEditAction.shift:
final shiftTotalSeconds = ((_shiftHour.value * minutesInHour + _shiftMinute.value) * secondsInMinute + _shiftSecond.value) * (_shiftSign.value == _positiveSign ? 1 : -1);
return DateModifier.shift(_fields, shiftTotalSeconds);
return DateModifier.shift(_fields, _timeShiftController.value.inSeconds);
case DateEditAction.remove:
return DateModifier.remove(_fields);
}

View file

@ -1,29 +1,40 @@
import 'dart:async';
import 'dart:convert';
import 'package:aves/app_mode.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/location.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/metadata/catalog.dart';
import 'package:aves/model/settings/enums/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/ref/poi.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/format.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/view/view.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/extensions/build_context.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/dialogs/aves_dialog.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/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:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:gpx/gpx.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
@ -31,12 +42,12 @@ import 'package:provider/provider.dart';
class EditEntryLocationDialog extends StatefulWidget {
static const routeName = '/dialog/edit_entry_location';
final AvesEntry entry;
final Set<AvesEntry> entries;
final CollectionLens? collection;
const EditEntryLocationDialog({
super.key,
required this.entry,
required this.entries,
this.collection,
});
@ -44,19 +55,26 @@ class EditEntryLocationDialog extends StatefulWidget {
State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState();
}
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> with FeedbackMixin {
final List<StreamSubscription> _subscriptions = [];
LocationEditAction _action = LocationEditAction.chooseOnMap;
LatLng? _mapCoordinates;
late final AvesEntry mainEntry;
late AvesEntry _copyItemSource;
Gpx? _gpx;
Duration _gpxShift = Duration.zero;
final Map<AvesEntry, LatLng> _gpxMap = {};
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale);
static const _minTimeToGpxPoint = Duration(hours: 1);
@override
void initState() {
super.initState();
final entries = widget.entries;
mainEntry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
_initMapCoordinates();
_initCopyItem();
_initCustom();
@ -64,16 +82,16 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
}
void _initMapCoordinates() {
_mapCoordinates = widget.entry.latLng;
_mapCoordinates = mainEntry.latLng;
}
void _initCopyItem() {
_copyItemSource = widget.entry;
_copyItemSource = mainEntry;
}
void _initCustom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final latLng = widget.entry.latLng;
final latLng = mainEntry.latLng;
if (latLng != null) {
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
@ -128,14 +146,9 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: AvesTransitions.formTransitionBuilder,
child: Column(
child: KeyedSubtree(
key: ValueKey(_action),
mainAxisSize: MainAxisSize.min,
children: [
if (_action == LocationEditAction.chooseOnMap) _buildChooseOnMapContent(context),
if (_action == LocationEditAction.copyItem) _buildCopyItemContent(context),
if (_action == LocationEditAction.setCustom) _buildSetCustomContent(context),
],
child: _buildContent(),
),
),
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) {
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Row(
children: [
Expanded(child: _toText(context, _mapCoordinates)),
Expanded(child: _coordinatesText(context, _mapCoordinates)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(AIcons.map),
@ -179,8 +207,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
_action = LocationEditAction.setCustom;
_validate();
setState(() {});
setState(_validate);
}
CollectionLens? _createPickCollection() {
@ -208,7 +235,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
fullscreenDialog: true,
),
);
pickCollection?.dispose();
if (latLng != null) {
settings.mapDefaultCenter = latLng;
setState(() {
@ -223,7 +249,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
child: Row(
children: [
Expanded(child: _toText(context, _copyItemSource.latLng)),
Expanded(child: _coordinatesText(context, _copyItemSource.latLng)),
const SizedBox(width: 8),
ItemPicker(
extent: 48,
@ -249,7 +275,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
fullscreenDialog: true,
),
);
pickCollection.dispose();
if (entry != null) {
setState(() {
_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;
if (latLng != null) {
return Text(
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
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 {
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(
l10n.viewerInfoUnknown,
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() {
@ -334,6 +586,8 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
_isValidNotifier.value = _copyItemSource.hasGps;
case LocationEditAction.setCustom:
_isValidNotifier.value = _parseLatLng() != null;
case LocationEditAction.importGpx:
_isValidNotifier.value = _gpxMap.isNotEmpty;
case LocationEditAction.remove:
_isValidNotifier.value = true;
}
@ -341,15 +595,23 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
void _submit(BuildContext 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) {
case LocationEditAction.chooseOnMap:
navigator?.pop(_mapCoordinates);
addLocationForAllEntries(_mapCoordinates);
case LocationEditAction.copyItem:
navigator?.pop(_copyItemSource.latLng);
addLocationForAllEntries(_copyItemSource.latLng);
case LocationEditAction.setCustom:
navigator?.pop(_parseLatLng());
addLocationForAllEntries(_parseLatLng());
case LocationEditAction.importGpx:
result.addAll(_gpxMap);
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> {
late final List<MetadataType> _mainOptions, _moreOptions;
late final List<MetadataType> _allOptions, _mainOptions, _moreOptions;
final Set<MetadataType> _types = {};
bool _showMore = false;
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
@ -37,10 +37,11 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
@override
void initState() {
super.initState();
final byMain = groupBy([
_allOptions = [
...MetadataTypes.common,
if (widget.showJpegTypes) ...MetadataTypes.jpeg,
], MetadataTypes.main.contains);
];
final byMain = groupBy(_allOptions, MetadataTypes.main.contains);
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);
_validate();
@ -59,6 +60,17 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
return AvesDialog(
title: l10n.removeEntryMetadataDialogTitle,
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),
if (_moreOptions.isNotEmpty)
Padding(
@ -131,8 +143,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
value: _types.contains(type),
onChanged: (selected) {
selected ? _types.add(type) : _types.remove(type);
_validate();
setState(() {});
setState(_validate);
},
title: Align(
alignment: Alignment.centerLeft,

View file

@ -33,17 +33,18 @@ class ItemPickPage extends StatefulWidget {
class _ItemPickPageState extends State<ItemPickPage> {
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization);
CollectionLens get collection => widget.collection;
@override
void dispose() {
collection.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();
}
@override
Widget build(BuildContext context) {
final collection = widget.collection;
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
_appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal;
return ListenableProvider<ValueNotifier<AppMode>>.value(

View file

@ -99,6 +99,9 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
_isPageAnimatingNotifier.dispose();
_dotLocationNotifier.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();
}

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