#105 mp4 metadata edit
This commit is contained in:
parent
59fe826e24
commit
71ff42997b
33 changed files with 1077 additions and 566 deletions
|
@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Collection / Info: edit MP4 metadata (date / location / title / description / rating / tags)
|
||||
- Widget: option to open collection on tap
|
||||
|
||||
## <a id="v1.7.1"></a>[v1.7.1] - 2022-10-09
|
||||
|
|
|
@ -148,23 +148,43 @@ flutter {
|
|||
}
|
||||
|
||||
repositories {
|
||||
maven { url 'https://jitpack.io' }
|
||||
maven { url 'https://s3.amazonaws.com/repo.commonsware.com' }
|
||||
maven {
|
||||
url 'https://jitpack.io'
|
||||
content {
|
||||
includeGroup "com.github.deckerst"
|
||||
includeGroup "com.github.deckerst.mp4parser"
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url 'https://s3.amazonaws.com/repo.commonsware.com'
|
||||
content {
|
||||
excludeGroupByRegex "com\\.github\\.deckerst.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.4'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.commonsware.cwac:document:0.5.0'
|
||||
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
|
||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
|
||||
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
|
||||
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||
implementation 'com.github.bumptech.glide:glide:4.14.2'
|
||||
// SLF4J implementation for `mp4parser`
|
||||
implementation 'org.slf4j:slf4j-simple:2.0.3'
|
||||
|
||||
// forked, built by JitPack:
|
||||
// - 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:876e53870a'
|
||||
implementation 'com.github.deckerst.mp4parser:isoparser:64b571fdfb'
|
||||
implementation 'com.github.deckerst.mp4parser:muxer:64b571fdfb'
|
||||
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
|
||||
|
||||
// huawei flavor only
|
||||
huaweiImplementation 'com.huawei.agconnect:agconnect-core:1.7.2.300'
|
||||
|
|
|
@ -50,7 +50,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
|
||||
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
|
||||
"getAppInstaller" -> ioScope.launch { safe(call, result, ::getAppInstaller) }
|
||||
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
|
||||
"edit" -> safe(call, result, ::edit)
|
||||
"open" -> safe(call, result, ::open)
|
||||
|
@ -187,23 +186,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun getAppInstaller(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
val packageName = context.packageName
|
||||
val pm = context.packageManager
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val info = pm.getInstallSourceInfo(packageName)
|
||||
result.success(info.initiatingPackageName ?: info.installingPackageName)
|
||||
} else {
|
||||
@Suppress("deprecation")
|
||||
result.success(pm.getInstallerPackageName(packageName))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("getAppInstaller-exception", "failed to get installer for packageName=$packageName", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val label = call.argument<String>("label")
|
||||
|
|
|
@ -18,10 +18,12 @@ import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
|||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.dumpBoxes
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper
|
||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithExifInterface
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithMetadataExtractor
|
||||
import deckers.thibault.aves.utils.MimeTypes.canReadWithPixyMeta
|
||||
|
@ -38,7 +40,9 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||
import org.mp4parser.IsoFile
|
||||
import java.io.IOException
|
||||
import java.nio.channels.Channels
|
||||
|
||||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
@ -60,6 +64,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
"getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
|
||||
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
|
||||
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
|
||||
"getMp4ParserDump" -> ioScope.launch { safe(call, result, ::getMp4ParserDump) }
|
||||
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
|
||||
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
|
||||
else -> result.notImplemented()
|
||||
|
@ -319,6 +324,32 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(metadataMap)
|
||||
}
|
||||
|
||||
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (mimeType == null || uri == null) {
|
||||
result.error("getMp4ParserDump-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val sb = StringBuilder()
|
||||
if (mimeType == MimeTypes.MP4) {
|
||||
try {
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
Channels.newChannel(input).use { channel ->
|
||||
IsoFile(channel).use { isoFile ->
|
||||
isoFile.dumpBoxes(sb)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
result.error("getMp4ParserDump-exception", e.message, e.stackTraceToString())
|
||||
return
|
||||
}
|
||||
}
|
||||
result.success(sb.toString())
|
||||
}
|
||||
|
||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
|
|
|
@ -68,7 +68,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
|
||||
provider.editOrientation(contextWrapper, path, uri, mimeType, op, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editOrientation-failure", "failed to change orientation for mimeType=$mimeType uri=$uri", throwable)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -98,7 +98,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
|
||||
provider.editDate(contextWrapper, path, uri, mimeType, dateMillis, shiftMinutes, fields, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editDate-failure", "failed to edit date for mimeType=$mimeType uri=$uri", throwable)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -127,7 +127,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
|
||||
provider.editMetadata(contextWrapper, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -154,7 +154,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
|
||||
provider.removeTrailerVideo(contextWrapper, path, uri, mimeType, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -182,7 +182,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
|||
|
||||
provider.removeMetadataTypes(contextWrapper, path, uri, mimeType, types.toSet(), object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
override fun onFailure(throwable: Throwable) = result.error("removeTypes-failure", "failed to remove metadata for mimeType=$mimeType uri=$uri", throwable)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -126,6 +126,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
var foundXmp = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta, dirMap: MutableMap<String, String>) {
|
||||
if (foundXmp) return
|
||||
foundXmp = true
|
||||
try {
|
||||
for (prop in xmpMeta) {
|
||||
if (prop is XMPPropertyInfo) {
|
||||
|
@ -148,14 +150,66 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
dirMap["schemaRegistryPrefixes"] = JSONObject(prefixes).toString()
|
||||
}
|
||||
|
||||
val mp4UuidDirCount = HashMap<String, Int>()
|
||||
fun processMp4Uuid(dir: Mp4UuidBoxDirectory) {
|
||||
var thisDirName: String
|
||||
when (val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
|
||||
GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
thisDirName = "Spherical Video"
|
||||
metadataMap[thisDirName] = HashMap(GSpherical(bytes).describe())
|
||||
}
|
||||
QuickTimeMetadata.PROF_UUID -> {
|
||||
// redundant with info derived on the Dart side
|
||||
}
|
||||
QuickTimeMetadata.USMT_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
|
||||
if (blocks.isNotEmpty()) {
|
||||
thisDirName = "QuickTime User Media"
|
||||
val usmt = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = usmt
|
||||
|
||||
blocks.forEach {
|
||||
var key = it.type
|
||||
var value = it.value
|
||||
val language = it.language
|
||||
|
||||
var i = 0
|
||||
while (usmt.containsKey(key)) {
|
||||
key = it.type + " (${++i})"
|
||||
}
|
||||
if (language != "und") {
|
||||
value += " ($language)"
|
||||
}
|
||||
usmt[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
val uuidPart = uuid.substringBefore('-')
|
||||
thisDirName = "${dir.name} $uuidPart"
|
||||
|
||||
val count = mp4UuidDirCount[uuidPart] ?: 0
|
||||
mp4UuidDirCount[uuidPart] = count + 1
|
||||
if (count > 0) {
|
||||
thisDirName += " ($count)"
|
||||
}
|
||||
|
||||
val dirMap = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = dirMap
|
||||
|
||||
dirMap.putAll(dir.tags.map { Pair(it.tagName, it.description) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||
|
||||
val uuidDirCount = HashMap<String, Int>()
|
||||
val dirByName = metadata.directories.filter {
|
||||
(it.tagCount > 0 || it.errorCount > 0)
|
||||
&& it !is FileTypeDirectory
|
||||
|
@ -177,24 +231,15 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
// directory name
|
||||
var thisDirName = baseDirName
|
||||
if (dir is Mp4UuidBoxDirectory) {
|
||||
val uuid = dir.getString(Mp4UuidBoxDirectory.TAG_UUID).substringBefore('-')
|
||||
thisDirName += " $uuid"
|
||||
|
||||
val count = uuidDirCount[uuid] ?: 0
|
||||
uuidDirCount[uuid] = count + 1
|
||||
if (count > 0) {
|
||||
thisDirName += " ($count)"
|
||||
}
|
||||
} else if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
|
||||
if (sameNameDirCount > 1 && !allMetadataMergeableDirNames.contains(baseDirName)) {
|
||||
// optional count for multiple directories of the same type
|
||||
thisDirName = "$thisDirName[${dirIndex + 1}]"
|
||||
}
|
||||
|
||||
// optional parent to distinguish child directories of the same type
|
||||
dir.parent?.name?.let { thisDirName = "$it/$thisDirName" }
|
||||
|
||||
var dirMap = metadataMap[thisDirName] ?: HashMap()
|
||||
if (dir !is Mp4UuidBoxDirectory) {
|
||||
metadataMap[thisDirName] = dirMap
|
||||
|
||||
// tags
|
||||
|
@ -287,48 +332,16 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
else -> dirMap.putAll(tags.map { Pair(it.tagName, it.description) })
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLargeMp4(mimeType, sizeBytes)) {
|
||||
if (dir is Mp4UuidBoxDirectory) {
|
||||
processMp4Uuid(dir)
|
||||
}
|
||||
|
||||
if (dir is XmpDirectory) {
|
||||
processXmp(dir.xmpMeta, dirMap)
|
||||
}
|
||||
|
||||
if (dir is Mp4UuidBoxDirectory) {
|
||||
when (dir.getString(Mp4UuidBoxDirectory.TAG_UUID)) {
|
||||
GSpherical.SPHERICAL_VIDEO_V1_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
metadataMap["Spherical Video"] = HashMap(GSpherical(bytes).describe())
|
||||
metadataMap.remove(thisDirName)
|
||||
}
|
||||
QuickTimeMetadata.PROF_UUID -> {
|
||||
// redundant with info derived on the Dart side
|
||||
metadataMap.remove(thisDirName)
|
||||
}
|
||||
QuickTimeMetadata.USMT_UUID -> {
|
||||
val bytes = dir.getByteArray(Mp4UuidBoxDirectory.TAG_USER_DATA)
|
||||
val blocks = QuickTimeMetadata.parseUuidUsmt(bytes)
|
||||
if (blocks.isNotEmpty()) {
|
||||
metadataMap.remove(thisDirName)
|
||||
thisDirName = "QuickTime User Media"
|
||||
val usmt = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = usmt
|
||||
|
||||
blocks.forEach {
|
||||
var key = it.type
|
||||
var value = it.value
|
||||
val language = it.language
|
||||
|
||||
var i = 0
|
||||
while (usmt.containsKey(key)) {
|
||||
key = it.type + " (${++i})"
|
||||
}
|
||||
if (language != "und") {
|
||||
value += " ($language)"
|
||||
}
|
||||
usmt[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// include errors, if any
|
||||
|
@ -367,13 +380,25 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, uri, mimeType, foundXmp) { xmpMeta ->
|
||||
fun fallbackProcessXmp(xmpMeta: XMPMeta) {
|
||||
val thisDirName = XmpDirectory().name
|
||||
val dirMap = metadataMap[thisDirName] ?: HashMap()
|
||||
metadataMap[thisDirName] = dirMap
|
||||
processXmp(xmpMeta, dirMap)
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::fallbackProcessXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
fallbackProcessXmp(dir.xmpMeta)
|
||||
}
|
||||
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
|
||||
processMp4Uuid(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo(mimeType)) {
|
||||
// this is used as fallback when the video metadata cannot be found on the Dart side
|
||||
// and to identify whether there is an accessible cover image
|
||||
|
@ -447,9 +472,9 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
val metadataMap = HashMap<String, Any>()
|
||||
getCatalogMetadataByMetadataExtractor(uri, mimeType, path, sizeBytes, metadataMap)
|
||||
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
|
||||
if (isVideo(mimeType) || isHeic(mimeType)) {
|
||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(uri, mimeType, metadataMap)
|
||||
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
|
||||
}
|
||||
|
||||
// report success even when empty
|
||||
|
@ -457,8 +482,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getCatalogMetadataByMetadataExtractor(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
uri: Uri,
|
||||
path: String?,
|
||||
sizeBytes: Long?,
|
||||
metadataMap: HashMap<String, Any>,
|
||||
|
@ -468,6 +493,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
var foundXmp = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta) {
|
||||
if (foundXmp) return
|
||||
foundXmp = true
|
||||
try {
|
||||
if (xmpMeta.doesPropExist(XMP.DC_SUBJECT_PROP_NAME)) {
|
||||
val values = xmpMeta.getPropArrayItemValues(XMP.DC_SUBJECT_PROP_NAME)
|
||||
|
@ -504,12 +531,18 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
fun processMp4Uuid(dir: Mp4UuidBoxDirectory) {
|
||||
// identification of spherical video (aka 360° video)
|
||||
if (dir.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID) {
|
||||
flags = flags or MASK_IS_360
|
||||
}
|
||||
}
|
||||
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundExif = metadata.directories.any { it is ExifDirectoryBase && it.tagCount > 0 }
|
||||
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||
|
||||
// File type
|
||||
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
|
||||
|
@ -565,6 +598,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
// XMP
|
||||
if (!isLargeMp4(mimeType, sizeBytes)) {
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||
|
||||
// XMP fallback to IPTC
|
||||
|
@ -578,6 +612,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (mimeType) {
|
||||
MimeTypes.PNG -> {
|
||||
|
@ -620,12 +655,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
// identification of spherical video (aka 360° video)
|
||||
if (metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).any {
|
||||
it.getString(Mp4UuidBoxDirectory.TAG_UUID) == GSpherical.SPHERICAL_VIDEO_V1_UUID
|
||||
}) {
|
||||
flags = flags or MASK_IS_360
|
||||
}
|
||||
metadata.getDirectoriesOfType(Mp4UuidBoxDirectory::class.java).forEach(::processMp4Uuid)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to read metadata by metadata-extractor for mimeType=$mimeType uri=$uri", e)
|
||||
|
@ -662,7 +692,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
processXmp(dir.xmpMeta)
|
||||
}
|
||||
for (dir in dirs.filterIsInstance<Mp4UuidBoxDirectory>()) {
|
||||
processMp4Uuid(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.TIFF && MultiPage.isMultiPageTiff(context, uri)) flags = flags or MASK_IS_MULTIPAGE
|
||||
|
||||
|
@ -670,8 +710,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
private fun getMultimediaCatalogMetadataByMediaMetadataRetriever(
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
uri: Uri,
|
||||
metadataMap: HashMap<String, Any>,
|
||||
) {
|
||||
val retriever = StorageUtils.openMetadataRetriever(context, uri) ?: return
|
||||
|
@ -862,10 +902,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
var foundXmp = false
|
||||
val fields: FieldMap = hashMapOf()
|
||||
var foundXmp = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta) {
|
||||
if (foundXmp) return
|
||||
foundXmp = true
|
||||
try {
|
||||
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_LEFT_PROP_NAME) { fields["croppedAreaLeft"] = it }
|
||||
xmpMeta.getSafeInt(XMP.GPANO_CROPPED_AREA_TOP_PROP_NAME) { fields["croppedAreaTop"] = it }
|
||||
|
@ -879,11 +921,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -895,7 +936,14 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
processXmp(dir.xmpMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.isEmpty()) {
|
||||
result.error("getPanoramaInfo-empty", "failed to get info for mimeType=$mimeType uri=$uri", null)
|
||||
|
@ -929,6 +977,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(null)
|
||||
}
|
||||
|
||||
// return XMP components
|
||||
// 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) }
|
||||
|
@ -938,10 +988,12 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
var foundXmp = false
|
||||
val xmpStrings = mutableListOf<String>()
|
||||
var foundXmp = false
|
||||
|
||||
fun processXmp(xmpMeta: XMPMeta) {
|
||||
if (foundXmp) return
|
||||
foundXmp = true
|
||||
try {
|
||||
xmpStrings.add(XMPMetaFactory.serializeToString(xmpMeta, xmpSerializeOptions))
|
||||
} catch (e: XMPException) {
|
||||
|
@ -949,11 +1001,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
if (canReadWithMetadataExtractor(mimeType)) {
|
||||
if (canReadWithMetadataExtractor(mimeType) && !isLargeMp4(mimeType, sizeBytes)) {
|
||||
try {
|
||||
Metadata.openSafeInputStream(context, uri, mimeType, sizeBytes)?.use { input ->
|
||||
val metadata = Helper.safeRead(input)
|
||||
foundXmp = metadata.directories.any { it is XmpDirectory && it.tagCount > 0 }
|
||||
metadata.getDirectoriesOfType(XmpDirectory::class.java).map { it.xmpMeta }.forEach(::processXmp)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
@ -968,14 +1019,17 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
|
||||
|
||||
if (xmpStrings.isEmpty()) {
|
||||
result.success(null)
|
||||
} else {
|
||||
result.success(xmpStrings)
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
|
||||
if (isLargeMp4(mimeType, sizeBytes)) {
|
||||
XMP.checkMp4(context, mimeType, uri) { dirs ->
|
||||
for (dir in dirs.filterIsInstance<XmpDirectory>()) {
|
||||
processXmp(dir.xmpMeta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.success(xmpStrings)
|
||||
}
|
||||
|
||||
private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
|
||||
val prop = call.argument<String>("prop")
|
||||
|
@ -1161,6 +1215,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
|||
omitXmpMetaElement = false // e.g. <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">...</x:xmpmeta>
|
||||
}
|
||||
|
||||
private fun isLargeMp4(mimeType: String, sizeBytes: Long?) = mimeType == MimeTypes.MP4 && Metadata.isDangerouslyLarge(sizeBytes)
|
||||
|
||||
private fun exifTagMapper(it: Tag): Pair<String, String> {
|
||||
val name = if (it.hasTagName()) {
|
||||
it.tagName
|
||||
|
|
|
@ -42,6 +42,7 @@ object Metadata {
|
|||
const val TYPE_JFIF = "jfif"
|
||||
const val TYPE_JPEG_ADOBE = "jpeg_adobe"
|
||||
const val TYPE_JPEG_DUCKY = "jpeg_ducky"
|
||||
const val TYPE_MP4 = "mp4"
|
||||
const val TYPE_PHOTOSHOP_IRB = "photoshop_irb"
|
||||
const val TYPE_XMP = "xmp"
|
||||
|
||||
|
@ -121,6 +122,8 @@ object Metadata {
|
|||
// It is not clear whether it is because of the file itself or its metadata.
|
||||
private const val fileSizeBytesMax = 100 * (1 shl 20) // MB
|
||||
|
||||
fun isDangerouslyLarge(sizeBytes: Long?) = sizeBytes == null || sizeBytes > fileSizeBytesMax
|
||||
|
||||
// we try and read metadata from large files by copying an arbitrary amount from its beginning
|
||||
// to a temporary file, and reusing that preview file for all metadata reading purposes
|
||||
private const val previewSize: Long = 5 * (1 shl 20) // MB
|
||||
|
@ -134,10 +137,7 @@ object Metadata {
|
|||
MimeTypes.PSD_VND,
|
||||
MimeTypes.PSD_X,
|
||||
MimeTypes.TIFF -> {
|
||||
if (sizeBytes != null && sizeBytes < fileSizeBytesMax) {
|
||||
// small enough to be safe as it is
|
||||
uri
|
||||
} else {
|
||||
if (isDangerouslyLarge(sizeBytes)) {
|
||||
// make a preview from the beginning of the file,
|
||||
// hoping the metadata is accessible in the copied chunk
|
||||
var previewFile = previewFiles[uri]
|
||||
|
@ -146,6 +146,9 @@ object Metadata {
|
|||
previewFiles[uri] = previewFile
|
||||
}
|
||||
Uri.fromFile(previewFile)
|
||||
} else {
|
||||
// small enough to be safe as it is
|
||||
uri
|
||||
}
|
||||
}
|
||||
// *probably* safe
|
||||
|
|
|
@ -0,0 +1,202 @@
|
|||
package deckers.thibault.aves.metadata
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import org.mp4parser.*
|
||||
import org.mp4parser.boxes.UserBox
|
||||
import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
||||
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MovieBox
|
||||
import org.mp4parser.boxes.iso14496.part12.UserDataBox
|
||||
import org.mp4parser.support.AbstractBox
|
||||
import org.mp4parser.tools.Path
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.nio.channels.Channels
|
||||
|
||||
object Mp4ParserHelper {
|
||||
private val LOG_TAG = LogUtils.createTag<Mp4ParserHelper>()
|
||||
|
||||
fun updateLocation(isoFile: IsoFile, locationIso6709: String?) {
|
||||
// Apple GPS Coordinates Box can be in various locations:
|
||||
// - moov[0]/udta[0]/©xyz
|
||||
// - moov[0]/meta[0]/ilst/©xyz
|
||||
// - others?
|
||||
isoFile.removeBoxes(AppleGPSCoordinatesBox::class.java, true)
|
||||
|
||||
locationIso6709 ?: return
|
||||
|
||||
val movieBox = isoFile.movieBox
|
||||
var userDataBox = Path.getPath<UserDataBox>(movieBox, UserDataBox.TYPE)
|
||||
if (userDataBox == null) {
|
||||
userDataBox = UserDataBox()
|
||||
movieBox.addBox(userDataBox)
|
||||
}
|
||||
|
||||
userDataBox.addBox(AppleGPSCoordinatesBox().apply {
|
||||
value = locationIso6709
|
||||
})
|
||||
}
|
||||
|
||||
fun updateXmp(isoFile: IsoFile, xmp: String?) {
|
||||
val xmpBox = isoFile.xmpBox
|
||||
if (xmp != null) {
|
||||
val xmpData = xmp.toByteArray(Charsets.UTF_8)
|
||||
if (xmpBox == null) {
|
||||
isoFile.addBox(UserBox(XMP.mp4Uuid).apply {
|
||||
data = xmpData
|
||||
})
|
||||
} else {
|
||||
xmpBox.data = xmpData
|
||||
}
|
||||
} else if (xmpBox != null) {
|
||||
isoFile.removeBox(xmpBox)
|
||||
}
|
||||
}
|
||||
|
||||
fun computeEdits(context: Context, uri: Uri, modifier: (isoFile: IsoFile) -> Unit): List<Pair<Long, ByteArray>> {
|
||||
// we can skip uninteresting boxes with a seekable data source
|
||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||
pfd.use {
|
||||
FileInputStream(it.fileDescriptor).use { stream ->
|
||||
stream.channel.use { channel ->
|
||||
val boxParser = PropertyBoxParserImpl().apply {
|
||||
skippingBoxes(MediaDataBox.TYPE)
|
||||
}
|
||||
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
||||
IsoFile(channel, boxParser).use { isoFile ->
|
||||
val lastContentBox = isoFile.boxes.reversed().firstOrNull { box ->
|
||||
when {
|
||||
box == isoFile.movieBox -> false
|
||||
testXmpBox(box) -> false
|
||||
box is FreeBox -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
lastContentBox ?: throw Exception("failed to find last context box")
|
||||
val oldFileSize = isoFile.size
|
||||
var appendOffset = (isoFile.getBoxOffset { box -> box == lastContentBox })!! + lastContentBox.size
|
||||
|
||||
val edits = arrayListOf<Pair<Long, ByteArray>>()
|
||||
fun addFreeBoxEdit(offset: Long, size: Long) = edits.add(Pair(offset, FreeBox(size.toInt() - 8).toBytes()))
|
||||
|
||||
// replace existing movie box by a free box
|
||||
isoFile.getBoxOffset { box -> box.type == MovieBox.TYPE }?.let { offset ->
|
||||
addFreeBoxEdit(offset, isoFile.movieBox.size)
|
||||
}
|
||||
|
||||
// replace existing XMP box by a free box
|
||||
isoFile.getBoxOffset { box -> testXmpBox(box) }?.let { offset ->
|
||||
addFreeBoxEdit(offset, isoFile.xmpBox!!.size)
|
||||
}
|
||||
|
||||
modifier(isoFile)
|
||||
|
||||
// write edited movie box
|
||||
val movieBoxBytes = isoFile.movieBox.toBytes()
|
||||
edits.removeAll { (offset, _) -> offset == appendOffset }
|
||||
edits.add(Pair(appendOffset, movieBoxBytes))
|
||||
appendOffset += movieBoxBytes.size
|
||||
|
||||
// write edited XMP box
|
||||
isoFile.xmpBox?.let { box ->
|
||||
edits.removeAll { (offset, _) -> offset == appendOffset }
|
||||
edits.add(Pair(appendOffset, box.toBytes()))
|
||||
appendOffset += box.size
|
||||
}
|
||||
|
||||
// write trailing free box instead of truncating
|
||||
val trailing = oldFileSize - appendOffset
|
||||
if (trailing > 0) {
|
||||
addFreeBoxEdit(appendOffset, trailing)
|
||||
}
|
||||
|
||||
return edits
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// according to XMP Specification Part 3 - Storage in Files,
|
||||
// XMP is embedded in MPEG-4 files using a top-level UUID box
|
||||
private fun testXmpBox(box: Box): Boolean {
|
||||
if (box is UserBox) {
|
||||
if (!box.isParsed) {
|
||||
box.parseDetails()
|
||||
}
|
||||
return box.userType.contentEquals(XMP.mp4Uuid)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extensions
|
||||
|
||||
private fun IsoFile.getBoxOffset(test: (box: Box) -> Boolean): Long? {
|
||||
var offset = 0L
|
||||
for (box in boxes) {
|
||||
if (test(box)) {
|
||||
return offset
|
||||
}
|
||||
offset += box.size
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private val IsoFile.xmpBox: UserBox?
|
||||
get() = boxes.firstOrNull { testXmpBox(it) } as UserBox?
|
||||
|
||||
fun <T : Box> Container.processBoxes(clazz: Class<T>, recursive: Boolean, apply: (box: T, parent: Container) -> Unit) {
|
||||
// use a copy, in case box processing removes boxes
|
||||
for (box in ArrayList(boxes)) {
|
||||
if (clazz.isInstance(box)) {
|
||||
@Suppress("unchecked_cast")
|
||||
apply(box as T, this)
|
||||
}
|
||||
if (recursive && box is Container) {
|
||||
box.processBoxes(clazz, true, apply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T : Box> Container.removeBoxes(clazz: Class<T>, recursive: Boolean) {
|
||||
processBoxes(clazz, recursive) { box, parent -> parent.removeBox(box) }
|
||||
}
|
||||
|
||||
private fun Container.removeBox(box: Box) {
|
||||
boxes = boxes.apply { remove(box) }
|
||||
}
|
||||
|
||||
fun Container.dumpBoxes(sb: StringBuilder, indent: Int = 0) {
|
||||
for (box in boxes) {
|
||||
val boxType = box.type
|
||||
try {
|
||||
if (box is AbstractBox && !box.isParsed) {
|
||||
box.parseDetails()
|
||||
}
|
||||
when (box) {
|
||||
is BasicContainer -> {
|
||||
sb.appendLine("${"\t".repeat(indent)}[$boxType] ${box.javaClass.simpleName}")
|
||||
box.dumpBoxes(sb, indent + 1)
|
||||
}
|
||||
is UserBox -> {
|
||||
val userTypeHex = box.userType.joinToString("") { "%02x".format(it) }
|
||||
sb.appendLine("${"\t".repeat(indent)}[$boxType] userType=$userTypeHex $box")
|
||||
}
|
||||
else -> sb.appendLine("${"\t".repeat(indent)}[$boxType] $box")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
sb.appendLine("${"\t".repeat(indent)}failed to access box type=$boxType exception=${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Box.toBytes(): ByteArray {
|
||||
val stream = ByteArrayOutputStream(size.toInt())
|
||||
Channels.newChannel(stream).use { getBox(it) }
|
||||
return stream.toByteArray()
|
||||
}
|
||||
}
|
|
@ -204,7 +204,7 @@ object MultiPage {
|
|||
Log.w(LOG_TAG, "failed to get motion photo offset from uri=$uri", e)
|
||||
}
|
||||
|
||||
XMP.checkHeic(context, uri, mimeType, foundXmp, ::processXmp)
|
||||
XMP.checkHeic(context, mimeType, uri, foundXmp, ::processXmp)
|
||||
|
||||
return offsetFromEnd
|
||||
}
|
||||
|
|
|
@ -10,16 +10,28 @@ import com.adobe.internal.xmp.XMPException
|
|||
import com.adobe.internal.xmp.XMPMeta
|
||||
import com.adobe.internal.xmp.XMPMetaFactory
|
||||
import com.adobe.internal.xmp.properties.XMPProperty
|
||||
import com.drew.metadata.Directory
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes
|
||||
import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes
|
||||
import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
|
||||
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
|
||||
import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.MimeTypes
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import org.mp4parser.IsoFile
|
||||
import org.mp4parser.PropertyBoxParserImpl
|
||||
import org.mp4parser.boxes.UserBox
|
||||
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
||||
import java.io.FileInputStream
|
||||
import java.util.*
|
||||
|
||||
object XMP {
|
||||
private val LOG_TAG = LogUtils.createTag<XMP>()
|
||||
|
||||
// BE7ACFCB 97A942E8 9C719994 91E3AFAC / BE7ACFCB-97A9-42E8-9C71-999491E3AFAC
|
||||
val mp4Uuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte())
|
||||
|
||||
// standard namespaces
|
||||
// cf com.adobe.internal.xmp.XMPConst
|
||||
private const val DC_NS_URI = "http://purl.org/dc/elements/1.1/"
|
||||
|
@ -94,7 +106,13 @@ object XMP {
|
|||
|
||||
// as of `metadata-extractor` v2.18.0, XMP is not discovered in HEIC images,
|
||||
// so we fall back to the native content resolver, if possible
|
||||
fun checkHeic(context: Context, uri: Uri, mimeType: String, foundXmp: Boolean, processXmp: (xmpMeta: XMPMeta) -> Unit) {
|
||||
fun checkHeic(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
uri: Uri,
|
||||
foundXmp: Boolean,
|
||||
processXmp: (xmpMeta: XMPMeta) -> Unit,
|
||||
) {
|
||||
if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
try {
|
||||
val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP)
|
||||
|
@ -108,6 +126,43 @@ object XMP {
|
|||
}
|
||||
}
|
||||
|
||||
// as of `metadata-extractor` v2.18.0, processing large MP4 files may crash,
|
||||
// so we fall back to parsing with `mp4parser`
|
||||
fun checkMp4(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
uri: Uri,
|
||||
processDirs: (dirs: List<Directory>) -> Unit,
|
||||
) {
|
||||
if (mimeType != MimeTypes.MP4) return
|
||||
try {
|
||||
// we can skip uninteresting boxes with a seekable data source
|
||||
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
|
||||
pfd.use {
|
||||
FileInputStream(it.fileDescriptor).use { stream ->
|
||||
stream.channel.use { channel ->
|
||||
val boxParser = PropertyBoxParserImpl().apply {
|
||||
skippingBoxes(MediaDataBox.TYPE)
|
||||
}
|
||||
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
||||
IsoFile(channel, boxParser).use { isoFile ->
|
||||
isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
|
||||
val bytes = box.toBytes()
|
||||
val payload = bytes.copyOfRange(8, bytes.size)
|
||||
|
||||
val metadata = com.drew.metadata.Metadata()
|
||||
SafeMp4UuidBoxHandler(metadata).processBox("", payload, -1, null)
|
||||
processDirs(metadata.directories.filter { dir -> dir.tagCount > 0 }.toList())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "failed to get XMP by MP4 parser for mimeType=$mimeType uri=$uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
// extensions
|
||||
|
||||
fun XMPMeta.isMotionPhoto(): Boolean {
|
||||
|
|
|
@ -4,20 +4,17 @@ import com.drew.imaging.mp4.Mp4Handler
|
|||
import com.drew.metadata.Metadata
|
||||
import com.drew.metadata.mp4.Mp4Context
|
||||
import com.drew.metadata.mp4.media.Mp4UuidBoxHandler
|
||||
import deckers.thibault.aves.metadata.XMP
|
||||
|
||||
class SafeMp4UuidBoxHandler(metadata: Metadata) : Mp4UuidBoxHandler(metadata) {
|
||||
override fun processBox(type: String?, payload: ByteArray?, boxSize: Long, context: Mp4Context?): Mp4Handler<*> {
|
||||
if (payload != null && payload.size >= 16) {
|
||||
val payloadUuid = payload.copyOfRange(0, 16)
|
||||
if (payloadUuid.contentEquals(xmpUuid)) {
|
||||
if (payloadUuid.contentEquals(XMP.mp4Uuid)) {
|
||||
SafeXmpReader().extract(payload, 16, payload.size - 16, metadata, directory)
|
||||
return this
|
||||
}
|
||||
}
|
||||
return super.processBox(type, payload, boxSize, context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val xmpUuid = byteArrayOf(0xbe.toByte(), 0x7a, 0xcf.toByte(), 0xcb.toByte(), 0x97.toByte(), 0xa9.toByte(), 0x42, 0xe8.toByte(), 0x9c.toByte(), 0x71, 0x99.toByte(), 0x94.toByte(), 0x91.toByte(), 0xe3.toByte(), 0xaf.toByte(), 0xac.toByte())
|
||||
}
|
||||
}
|
|
@ -22,6 +22,10 @@ import deckers.thibault.aves.decoder.SvgImage
|
|||
import deckers.thibault.aves.decoder.TiffImage
|
||||
import deckers.thibault.aves.metadata.*
|
||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_IPTC
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_MP4
|
||||
import deckers.thibault.aves.metadata.Metadata.TYPE_XMP
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper.extendedXmpDocString
|
||||
import deckers.thibault.aves.metadata.PixyMetaHelper.xmpDocString
|
||||
import deckers.thibault.aves.model.AvesEntry
|
||||
|
@ -37,10 +41,8 @@ import deckers.thibault.aves.utils.MimeTypes.canEditXmp
|
|||
import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata
|
||||
import deckers.thibault.aves.utils.MimeTypes.extensionFor
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.io.*
|
||||
import java.nio.channels.Channels
|
||||
import java.util.*
|
||||
|
||||
abstract class ImageProvider {
|
||||
|
@ -350,6 +352,7 @@ abstract class ImageProvider {
|
|||
|
||||
// copy the edited temporary file back to the original
|
||||
DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile)
|
||||
editableFile.delete()
|
||||
}
|
||||
|
||||
val fileName = targetDocFile.name
|
||||
|
@ -457,11 +460,12 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
// copy the edited temporary file back to the original
|
||||
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
editableFile.delete()
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
|
@ -524,7 +528,7 @@ abstract class ImageProvider {
|
|||
iptc != null ->
|
||||
PixyMetaHelper.setIptc(input, output, iptc)
|
||||
canRemoveMetadata(mimeType) ->
|
||||
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_IPTC))
|
||||
PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_IPTC))
|
||||
else -> {
|
||||
Log.w(LOG_TAG, "setting empty IPTC for mimeType=$mimeType")
|
||||
PixyMetaHelper.setIptc(input, output, null)
|
||||
|
@ -539,11 +543,12 @@ abstract class ImageProvider {
|
|||
}
|
||||
|
||||
// copy the edited temporary file back to the original
|
||||
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
editableFile.delete()
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
|
@ -552,6 +557,60 @@ abstract class ImageProvider {
|
|||
return true
|
||||
}
|
||||
|
||||
private fun editMp4Metadata(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
fields: Map<*, *>
|
||||
): Boolean {
|
||||
if (mimeType != MimeTypes.MP4) {
|
||||
callback.onFailure(UnsupportedOperationException("unsupported mimeType=$mimeType"))
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
val edits = Mp4ParserHelper.computeEdits(context, uri) { isoFile ->
|
||||
fields.forEach { kv ->
|
||||
val tag = kv.key as String
|
||||
val value = kv.value as String?
|
||||
when (tag) {
|
||||
"gpsCoordinates" -> Mp4ParserHelper.updateLocation(isoFile, value)
|
||||
"xmp" -> Mp4ParserHelper.updateXmp(isoFile, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pfd = StorageUtils.openOutputFileDescriptor(
|
||||
context = context,
|
||||
mimeType = mimeType,
|
||||
uri = uri,
|
||||
path = path,
|
||||
// do not truncate
|
||||
mode = "w",
|
||||
) ?: throw Exception("failed to open file descriptor for uri=$uri path=$path")
|
||||
pfd.use {
|
||||
FileOutputStream(it.fileDescriptor).use { outputStream ->
|
||||
outputStream.channel.use { outputChannel ->
|
||||
edits.forEach { (offset, bytes) ->
|
||||
bytes.inputStream().use { inputStream ->
|
||||
Channels.newChannel(inputStream).use { inputChannel ->
|
||||
outputChannel.transferFrom(inputChannel, offset, bytes.size.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// provide `editCoreXmp` to modify existing core XMP,
|
||||
// or provide `coreXmp` and `extendedXmp` to set them
|
||||
private fun editXmp(
|
||||
|
@ -571,11 +630,62 @@ abstract class ImageProvider {
|
|||
return false
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.MP4) {
|
||||
return editMp4Metadata(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
fields = mapOf("xmp" to coreXmp),
|
||||
)
|
||||
}
|
||||
|
||||
val originalFileSize = File(path).length()
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
editXmpWithPixy(
|
||||
context = context,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
coreXmp = coreXmp,
|
||||
extendedXmp = extendedXmp,
|
||||
editCoreXmp = editCoreXmp,
|
||||
editableFile = this
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 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)) {
|
||||
return false
|
||||
}
|
||||
editableFile.delete()
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun editXmpWithPixy(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
coreXmp: String?,
|
||||
extendedXmp: String?,
|
||||
editCoreXmp: ((xmp: String) -> String)?,
|
||||
editableFile: File
|
||||
) {
|
||||
var editedXmpString = coreXmp
|
||||
var editedExtendedXmp = extendedXmp
|
||||
if (editCoreXmp != null) {
|
||||
|
@ -588,7 +698,7 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
outputStream().use { output ->
|
||||
editableFile.outputStream().use { output ->
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
if (editedXmpString != null) {
|
||||
|
@ -599,32 +709,13 @@ abstract class ImageProvider {
|
|||
PixyMetaHelper.setXmp(input, output, editedXmpString, editedExtendedXmp)
|
||||
}
|
||||
} else if (canRemoveMetadata(mimeType)) {
|
||||
PixyMetaHelper.removeMetadata(input, output, setOf(Metadata.TYPE_XMP))
|
||||
PixyMetaHelper.removeMetadata(input, output, setOf(TYPE_XMP))
|
||||
} else {
|
||||
Log.w(LOG_TAG, "setting empty XMP for mimeType=$mimeType")
|
||||
PixyMetaHelper.setXmp(input, output, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// A few bytes are sometimes appended when writing to a document output stream.
|
||||
|
@ -807,8 +898,8 @@ abstract class ImageProvider {
|
|||
autoCorrectTrailerOffset: Boolean,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
if (modifier.containsKey("exif")) {
|
||||
val fields = modifier["exif"] as Map<*, *>?
|
||||
if (modifier.containsKey(TYPE_EXIF)) {
|
||||
val fields = modifier[TYPE_EXIF] as Map<*, *>?
|
||||
if (fields != null && fields.isNotEmpty()) {
|
||||
if (!editExif(
|
||||
context = context,
|
||||
|
@ -825,7 +916,7 @@ abstract class ImageProvider {
|
|||
val value = kv.value
|
||||
if (value == null) {
|
||||
// remove attribute
|
||||
exif.setAttribute(tag, value)
|
||||
exif.setAttribute(tag, null)
|
||||
} else {
|
||||
when (tag) {
|
||||
ExifInterface.TAG_GPS_LATITUDE,
|
||||
|
@ -864,8 +955,8 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
|
||||
if (modifier.containsKey("iptc")) {
|
||||
val iptc = (modifier["iptc"] as List<*>?)?.filterIsInstance<FieldMap>()
|
||||
if (modifier.containsKey(TYPE_IPTC)) {
|
||||
val iptc = (modifier[TYPE_IPTC] as List<*>?)?.filterIsInstance<FieldMap>()
|
||||
if (!editIptc(
|
||||
context = context,
|
||||
path = path,
|
||||
|
@ -878,8 +969,23 @@ abstract class ImageProvider {
|
|||
) return
|
||||
}
|
||||
|
||||
if (modifier.containsKey("xmp")) {
|
||||
val xmp = modifier["xmp"] as Map<*, *>?
|
||||
if (modifier.containsKey(TYPE_MP4)) {
|
||||
val fields = modifier[TYPE_MP4] as Map<*, *>?
|
||||
if (fields != null && fields.isNotEmpty()) {
|
||||
if (!editMp4Metadata(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
fields = fields,
|
||||
)
|
||||
) return
|
||||
}
|
||||
}
|
||||
|
||||
if (modifier.containsKey(TYPE_XMP)) {
|
||||
val xmp = modifier[TYPE_XMP] as Map<*, *>?
|
||||
if (xmp != null) {
|
||||
val coreXmp = xmp["xmp"] as String?
|
||||
val extendedXmp = xmp["extendedXmp"] as String?
|
||||
|
@ -930,7 +1036,8 @@ abstract class ImageProvider {
|
|||
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
editableFile.delete()
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
|
@ -973,11 +1080,12 @@ abstract class ImageProvider {
|
|||
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
copyFileTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||
|
||||
if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return
|
||||
}
|
||||
editableFile.delete()
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
|
@ -987,21 +1095,20 @@ abstract class ImageProvider {
|
|||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
|
||||
private fun copyFileTo(
|
||||
private fun outputStream(
|
||||
context: Context,
|
||||
mimeType: String,
|
||||
sourceFile: File,
|
||||
targetUri: Uri,
|
||||
targetPath: String
|
||||
) {
|
||||
uri: Uri,
|
||||
path: String
|
||||
): OutputStream {
|
||||
// truncate is necessary when overwriting a longer file
|
||||
val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
|
||||
StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri")
|
||||
val mode = "wt"
|
||||
return if (isMediaUriPermissionGranted(context, uri, mimeType)) {
|
||||
StorageUtils.openOutputStream(context, mimeType, uri, mode) ?: throw Exception("failed to open output stream for uri=$uri")
|
||||
} else {
|
||||
val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
|
||||
context.contentResolver.openOutputStream(documentUri, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri")
|
||||
val documentUri = StorageUtils.getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri")
|
||||
context.contentResolver.openOutputStream(documentUri, mode) ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$path, uri=$uri")
|
||||
}
|
||||
sourceFile.transferTo(targetStream)
|
||||
}
|
||||
|
||||
interface ImageOpCallback {
|
||||
|
|
|
@ -104,28 +104,28 @@ object MimeTypes {
|
|||
else -> false
|
||||
}
|
||||
|
||||
// as of androidx.exifinterface:exifinterface:1.3.4
|
||||
fun canEditExif(mimeType: String) = when (mimeType) {
|
||||
JPEG,
|
||||
PNG,
|
||||
WEBP -> true
|
||||
// as of androidx.exifinterface:exifinterface:1.3.4
|
||||
JPEG, PNG, WEBP -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canEditIptc(mimeType: String) = when (mimeType) {
|
||||
// as of latest PixyMeta
|
||||
JPEG, TIFF -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canEditXmp(mimeType: String) = when (mimeType) {
|
||||
// as of latest PixyMeta
|
||||
JPEG, TIFF, PNG, GIF -> true
|
||||
// using `mp4parser`
|
||||
MP4 -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
// as of latest PixyMeta
|
||||
fun canRemoveMetadata(mimeType: String) = when (mimeType) {
|
||||
// as of latest PixyMeta
|
||||
JPEG, TIFF -> true
|
||||
else -> false
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ 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
|
||||
import android.provider.MediaStore
|
||||
|
@ -17,6 +18,7 @@ import android.text.TextUtils
|
|||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.model.provider.ImageProvider
|
||||
import deckers.thibault.aves.utils.FileUtils.transferFrom
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
|
@ -580,19 +582,47 @@ object StorageUtils {
|
|||
} catch (e: Exception) {
|
||||
// among various other exceptions,
|
||||
// opening a file marked pending and owned by another package throws an `IllegalStateException`
|
||||
Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e)
|
||||
Log.w(LOG_TAG, "failed to open input stream from effectiveUri=$effectiveUri for uri=$uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? {
|
||||
fun openOutputStream(context: Context, mimeType: String, uri: Uri, mode: String): OutputStream? {
|
||||
val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
|
||||
return try {
|
||||
context.contentResolver.openOutputStream(effectiveUri, mode)
|
||||
} catch (e: Exception) {
|
||||
// among various other exceptions,
|
||||
// opening a file marked pending and owned by another package throws an `IllegalStateException`
|
||||
Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e)
|
||||
Log.w(LOG_TAG, "failed to open output stream from effectiveUri=$effectiveUri for uri=$uri mode=$mode", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun openInputFileDescriptor(context: Context, uri: Uri): ParcelFileDescriptor? {
|
||||
val effectiveUri = getOriginalUri(context, uri)
|
||||
return try {
|
||||
context.contentResolver.openFileDescriptor(effectiveUri, "r")
|
||||
} catch (e: Exception) {
|
||||
// among various other exceptions,
|
||||
// opening a file marked pending and owned by another package throws an `IllegalStateException`
|
||||
Log.w(LOG_TAG, "failed to open input file descriptor from effectiveUri=$effectiveUri for uri=$uri", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun openOutputFileDescriptor(context: Context, mimeType: String, uri: Uri, path: String, mode: String): ParcelFileDescriptor? {
|
||||
val effectiveUri = if (ImageProvider.isMediaUriPermissionGranted(context, uri, mimeType)) {
|
||||
getMediaStoreScopedStorageSafeUri(uri, mimeType)
|
||||
} else {
|
||||
getDocumentFile(context, path, uri)?.uri ?: throw Exception("failed to get document file for path=$path, uri=$uri")
|
||||
}
|
||||
return try {
|
||||
context.contentResolver.openFileDescriptor(effectiveUri, mode)
|
||||
} catch (e: Exception) {
|
||||
// among various other exceptions,
|
||||
// opening a file marked pending and owned by another package throws an `IllegalStateException`
|
||||
Log.w(LOG_TAG, "failed to open output file descriptor from effectiveUri=$effectiveUri for uri=$uri path=$path", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ buildscript {
|
|||
maven { url 'https://developer.huawei.com/repo/' }
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.3.0'
|
||||
classpath 'com.android.tools.build:gradle:7.3.1'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
// GMS & Firebase Crashlytics (used by some flavors only)
|
||||
classpath 'com.google.gms:google-services:4.3.14'
|
||||
|
|
|
@ -284,7 +284,7 @@ class AvesEntry {
|
|||
|
||||
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
|
||||
|
||||
bool get canEditLocation => canEdit && canEditExif;
|
||||
bool get canEditLocation => canEdit && (canEditExif || mimeType == MimeTypes.mp4);
|
||||
|
||||
bool get canEditTitleDescription => canEdit && canEditXmp;
|
||||
|
||||
|
@ -294,54 +294,13 @@ class AvesEntry {
|
|||
|
||||
bool get canRotateAndFlip => canEdit && canEditExif;
|
||||
|
||||
// `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes,
|
||||
// and DNG requires DNG-specific tags saved along standard Exif. So it was actually breaking DNG files.
|
||||
// as of androidx.exifinterface:exifinterface:1.3.4
|
||||
bool get canEditExif {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.png:
|
||||
case MimeTypes.webp:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool get canEditExif => MimeTypes.canEditExif(mimeType);
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canEditIptc {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool get canEditIptc => MimeTypes.canEditIptc(mimeType);
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canEditXmp {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.gif:
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.png:
|
||||
case MimeTypes.tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool get canEditXmp => MimeTypes.canEditXmp(mimeType);
|
||||
|
||||
// as of latest PixyMeta
|
||||
bool get canRemoveMetadata {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case MimeTypes.jpeg:
|
||||
case MimeTypes.tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
bool get canRemoveMetadata => MimeTypes.canRemoveMetadata(mimeType);
|
||||
|
||||
// Media Store size/rotation is inaccurate, e.g. a portrait FHD video is rotated according to its metadata,
|
||||
// so it should be registered as width=1920, height=1080, orientation=90,
|
||||
|
|
|
@ -7,11 +7,13 @@ import 'package:aves/model/metadata/enums.dart';
|
|||
import 'package:aves/model/metadata/fields.dart';
|
||||
import 'package:aves/ref/exif.dart';
|
||||
import 'package:aves/ref/iptc.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/metadata/xmp.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:aves/utils/xmp_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
@ -82,9 +84,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
|
||||
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
|
||||
final Set<EntryDataType> dataTypes = {};
|
||||
final Map<MetadataType, dynamic> metadata = {};
|
||||
|
||||
await _missingDateCheckAndExifEdit(dataTypes);
|
||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
||||
|
||||
if (canEditExif) {
|
||||
// clear every GPS field
|
||||
final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null)));
|
||||
// add latitude & longitude, if any
|
||||
|
@ -100,10 +104,43 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
});
|
||||
}
|
||||
}
|
||||
metadata[MetadataType.exif] = Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value)));
|
||||
|
||||
if (canEditXmp && missingDate != null) {
|
||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
||||
editCreateDateXmp(descriptions, missingDate);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mimeType == MimeTypes.mp4) {
|
||||
final mp4Fields = <MetadataField, String?>{};
|
||||
|
||||
String? iso6709String;
|
||||
if (latLng != null) {
|
||||
final latitude = latLng.latitude;
|
||||
final longitude = latLng.longitude;
|
||||
if (latitude != 0 && longitude != 0) {
|
||||
const locale = 'en_US';
|
||||
final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}';
|
||||
final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}';
|
||||
iso6709String = '$isoLat$isoLon/';
|
||||
}
|
||||
}
|
||||
mp4Fields[MetadataField.mp4GpsCoordinates] = iso6709String;
|
||||
|
||||
if (missingDate != null) {
|
||||
final xmpParts = await _editXmp((descriptions) {
|
||||
editCreateDateXmp(descriptions, missingDate);
|
||||
return true;
|
||||
});
|
||||
mp4Fields[MetadataField.mp4Xmp] = xmpParts[xmpCoreKey];
|
||||
}
|
||||
|
||||
metadata[MetadataType.mp4] = Map<String, String?>.fromEntries(mp4Fields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value)));
|
||||
}
|
||||
|
||||
final metadata = {
|
||||
MetadataType.exif: Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.exifInterfaceTag!, kv.value))),
|
||||
};
|
||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
||||
if (newFields.isNotEmpty) {
|
||||
dataTypes.addAll({
|
||||
|
@ -160,7 +197,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
final description = fields[DescriptionField.description];
|
||||
|
||||
if (canEditExif && editDescription) {
|
||||
metadata[MetadataType.exif] = {MetadataField.exifImageDescription.exifInterfaceTag!: description};
|
||||
metadata[MetadataType.exif] = {MetadataField.exifImageDescription.toPlatform!: description};
|
||||
}
|
||||
|
||||
if (canEditIptc) {
|
||||
|
@ -480,10 +517,17 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
static const xmpCoreKey = 'xmp';
|
||||
static const xmpExtendedKey = 'extendedXmp';
|
||||
|
||||
Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
|
||||
final xmp = await metadataFetchService.getXmp(this);
|
||||
final xmpString = xmp?.xmpString;
|
||||
final extendedXmpString = xmp?.extendedXmpString;
|
||||
if (xmp == null) {
|
||||
throw Exception('failed to get XMP');
|
||||
}
|
||||
|
||||
final xmpString = xmp.xmpString;
|
||||
final extendedXmpString = xmp.extendedXmpString;
|
||||
|
||||
final editedXmpString = await XMP.edit(
|
||||
xmpString,
|
||||
|
@ -493,8 +537,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
|
||||
final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString);
|
||||
return {
|
||||
'xmp': editedXmp.xmpString,
|
||||
'extendedXmp': editedXmp.extendedXmpString,
|
||||
xmpCoreKey: editedXmp.xmpString,
|
||||
xmpExtendedKey: editedXmp.extendedXmpString,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ enum MetadataType {
|
|||
jpegAdobe,
|
||||
// JPEG APP12 / Ducky: https://www.exiftool.org/TagNames/APP12.html#Ducky
|
||||
jpegDucky,
|
||||
// ISO User Data box content, etc.
|
||||
mp4,
|
||||
// Photoshop IRB: https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/
|
||||
photoshopIrb,
|
||||
// XMP: https://en.wikipedia.org/wiki/Extensible_Metadata_Platform
|
||||
|
@ -78,12 +80,39 @@ extension ExtraMetadataType on MetadataType {
|
|||
return 'Adobe JPEG';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'Ducky';
|
||||
case MetadataType.mp4:
|
||||
return 'MP4';
|
||||
case MetadataType.photoshopIrb:
|
||||
return 'Photoshop';
|
||||
case MetadataType.xmp:
|
||||
return 'XMP';
|
||||
}
|
||||
}
|
||||
|
||||
String get toPlatform {
|
||||
switch (this) {
|
||||
case MetadataType.comment:
|
||||
return 'comment';
|
||||
case MetadataType.exif:
|
||||
return 'exif';
|
||||
case MetadataType.iccProfile:
|
||||
return 'icc_profile';
|
||||
case MetadataType.iptc:
|
||||
return 'iptc';
|
||||
case MetadataType.jfif:
|
||||
return 'jfif';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'jpeg_adobe';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'jpeg_ducky';
|
||||
case MetadataType.mp4:
|
||||
return 'mp4';
|
||||
case MetadataType.photoshopIrb:
|
||||
return 'photoshop_irb';
|
||||
case MetadataType.xmp:
|
||||
return 'xmp';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ExtraDateFieldSource on DateFieldSource {
|
||||
|
|
|
@ -37,6 +37,8 @@ enum MetadataField {
|
|||
exifGpsTrackRef,
|
||||
exifGpsVersionId,
|
||||
exifImageDescription,
|
||||
mp4GpsCoordinates,
|
||||
mp4Xmp,
|
||||
xmpXmpCreateDate,
|
||||
}
|
||||
|
||||
|
@ -117,12 +119,30 @@ extension ExtraMetadataField on MetadataField {
|
|||
case MetadataField.exifGpsVersionId:
|
||||
case MetadataField.exifImageDescription:
|
||||
return MetadataType.exif;
|
||||
case MetadataField.mp4GpsCoordinates:
|
||||
case MetadataField.mp4Xmp:
|
||||
return MetadataType.mp4;
|
||||
case MetadataField.xmpXmpCreateDate:
|
||||
return MetadataType.xmp;
|
||||
}
|
||||
}
|
||||
|
||||
String? get exifInterfaceTag {
|
||||
String? get toPlatform {
|
||||
if (type == MetadataType.exif) {
|
||||
return _toExifInterfaceTag();
|
||||
} else {
|
||||
switch (this) {
|
||||
case MetadataField.mp4GpsCoordinates:
|
||||
return 'gpsCoordinates';
|
||||
case MetadataField.mp4Xmp:
|
||||
return 'xmp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String? _toExifInterfaceTag() {
|
||||
switch (this) {
|
||||
case MetadataField.exifDate:
|
||||
return 'DateTime';
|
||||
|
@ -196,7 +216,7 @@ extension ExtraMetadataField on MetadataField {
|
|||
return 'GPSVersionID';
|
||||
case MetadataField.exifImageDescription:
|
||||
return 'ImageDescription';
|
||||
case MetadataField.xmpXmpCreateDate:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ extension ExtraDisplayRefreshRateMode on DisplayRefreshRateMode {
|
|||
if (!await windowService.isActivity()) return;
|
||||
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
if ((androidInfo.version.sdkInt ?? 0) < 23) return;
|
||||
if (androidInfo.version.sdkInt < 23) return;
|
||||
|
||||
debugPrint('Apply display refresh rate: $name');
|
||||
switch (this) {
|
||||
|
|
|
@ -136,4 +136,56 @@ class MimeTypes {
|
|||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `exifinterface` v1.3.3 declared support for DNG, but it strips non-standard Exif tags when saving attributes,
|
||||
// and DNG requires DNG-specific tags saved along standard Exif. So it was actually breaking DNG files.
|
||||
static bool canEditExif(String mimeType) {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
// as of androidx.exifinterface:exifinterface:1.3.4
|
||||
case jpeg:
|
||||
case png:
|
||||
case webp:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool canEditIptc(String mimeType) {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
// as of latest PixyMeta
|
||||
case jpeg:
|
||||
case tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool canEditXmp(String mimeType) {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
// as of latest PixyMeta
|
||||
case gif:
|
||||
case jpeg:
|
||||
case png:
|
||||
case tiff:
|
||||
return true;
|
||||
// using `mp4parser`
|
||||
case mp4:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool canRemoveMetadata(String mimeType) {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
// as of latest PixyMeta
|
||||
case jpeg:
|
||||
case tiff:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,6 @@ abstract class AndroidAppService {
|
|||
|
||||
Future<Uint8List> getAppIcon(String packageName, double size);
|
||||
|
||||
Future<String?> getAppInstaller();
|
||||
|
||||
Future<bool> copyToClipboard(String uri, String? label);
|
||||
|
||||
Future<bool> edit(String uri, String mimeType);
|
||||
|
@ -73,16 +71,6 @@ class PlatformAndroidAppService implements AndroidAppService {
|
|||
return Uint8List(0);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getAppInstaller() async {
|
||||
try {
|
||||
return await _platform.invokeMethod('getAppInstaller');
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> copyToClipboard(String uri, String? label) async {
|
||||
try {
|
||||
|
|
|
@ -146,6 +146,18 @@ class AndroidDebugService {
|
|||
return {};
|
||||
}
|
||||
|
||||
static Future<String?> getMp4ParserDump(AvesEntry entry) async {
|
||||
try {
|
||||
return await _platform.invokeMethod('getMp4ParserDump', <String, dynamic>{
|
||||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<Map> getPixyMetadata(AvesEntry entry) async {
|
||||
try {
|
||||
// returns map with all data available from the `PixyMeta` library
|
||||
|
|
|
@ -65,7 +65,7 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
'entry': entry.toPlatformEntryMap(),
|
||||
'dateMillis': modifier.setDateTime?.millisecondsSinceEpoch,
|
||||
'shiftMinutes': modifier.shiftMinutes,
|
||||
'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.exifInterfaceTag).whereNotNull().toList(),
|
||||
'fields': modifier.fields.where((v) => v.type == MetadataType.exif).map((v) => v.toPlatform).whereNotNull().toList(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
|
@ -85,7 +85,7 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
try {
|
||||
final result = await _platform.invokeMethod('editMetadata', <String, dynamic>{
|
||||
'entry': entry.toPlatformEntryMap(),
|
||||
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
|
||||
'metadata': metadata.map((type, value) => MapEntry(type.toPlatform, value)),
|
||||
'autoCorrectTrailerOffset': autoCorrectTrailerOffset,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
|
@ -117,7 +117,7 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
try {
|
||||
final result = await _platform.invokeMethod('removeTypes', <String, dynamic>{
|
||||
'entry': entry.toPlatformEntryMap(),
|
||||
'types': types.map(_toPlatformMetadataType).toList(),
|
||||
'types': types.map((v) => v.toPlatform).toList(),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
|
@ -127,27 +127,4 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
String _toPlatformMetadataType(MetadataType type) {
|
||||
switch (type) {
|
||||
case MetadataType.comment:
|
||||
return 'comment';
|
||||
case MetadataType.exif:
|
||||
return 'exif';
|
||||
case MetadataType.iccProfile:
|
||||
return 'icc_profile';
|
||||
case MetadataType.iptc:
|
||||
return 'iptc';
|
||||
case MetadataType.jfif:
|
||||
return 'jfif';
|
||||
case MetadataType.jpegAdobe:
|
||||
return 'jpeg_adobe';
|
||||
case MetadataType.jpegDucky:
|
||||
return 'jpeg_ducky';
|
||||
case MetadataType.photoshopIrb:
|
||||
return 'photoshop_irb';
|
||||
case MetadataType.xmp:
|
||||
return 'xmp';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -259,7 +259,7 @@ class PlatformMetadataFetchService implements MetadataFetchService {
|
|||
'mimeType': entry.mimeType,
|
||||
'uri': entry.uri,
|
||||
'sizeBytes': entry.sizeBytes,
|
||||
'field': field.exifInterfaceTag,
|
||||
'field': field.toPlatform,
|
||||
});
|
||||
if (result is int) {
|
||||
return dateTimeFromMillis(result, isUtc: false);
|
||||
|
|
|
@ -15,10 +15,10 @@ class AvesXmp extends Equatable {
|
|||
this.extendedXmpString,
|
||||
});
|
||||
|
||||
static AvesXmp? fromList(List<String> xmpStrings) {
|
||||
static AvesXmp fromList(List<String> xmpStrings) {
|
||||
switch (xmpStrings.length) {
|
||||
case 0:
|
||||
return null;
|
||||
return const AvesXmp(xmpString: null);
|
||||
case 1:
|
||||
return AvesXmp(xmpString: xmpStrings.single);
|
||||
default:
|
||||
|
|
|
@ -68,48 +68,59 @@ class Constants {
|
|||
|
||||
static const String avesGithub = 'https://github.com/deckerst/aves';
|
||||
|
||||
static const String apache2 = 'Apache License 2.0';
|
||||
static const String bsd2 = 'BSD 2-Clause "Simplified" License';
|
||||
static const String bsd3 = 'BSD 3-Clause "Revised" License';
|
||||
static const String eclipse1 = 'Eclipse Public License 1.0';
|
||||
static const String mit = 'MIT License';
|
||||
|
||||
static const List<Dependency> androidDependencies = [
|
||||
Dependency(
|
||||
name: 'AndroidX Core-KTX',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
|
||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/core/core-ktx',
|
||||
),
|
||||
Dependency(
|
||||
name: 'AndroidX Exifinterface',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
licenseUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/LICENSE.txt',
|
||||
sourceUrl: 'https://android.googlesource.com/platform/frameworks/support/+/androidx-main/exifinterface/exifinterface',
|
||||
),
|
||||
Dependency(
|
||||
name: 'AndroidSVG',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/BigBadaboom/androidsvg',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Android-TiffBitmapFactory (Aves fork)',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
licenseUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory/blob/master/license.txt',
|
||||
sourceUrl: 'https://github.com/deckerst/Android-TiffBitmapFactory',
|
||||
),
|
||||
Dependency(
|
||||
name: 'CWAC-Document',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/commonsguy/cwac-document',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Glide',
|
||||
license: 'Apache 2.0, BSD 2-Clause',
|
||||
license: '$apache2, $bsd2',
|
||||
sourceUrl: 'https://github.com/bumptech/glide',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Metadata Extractor',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
|
||||
),
|
||||
Dependency(
|
||||
name: 'MP4 Parser (Aves fork)',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/deckerst/mp4parser',
|
||||
),
|
||||
Dependency(
|
||||
name: 'PixyMeta Android (Aves fork)',
|
||||
license: 'Eclipse Public License 1.0',
|
||||
license: eclipse1,
|
||||
sourceUrl: 'https://github.com/deckerst/pixymeta-android',
|
||||
),
|
||||
];
|
||||
|
@ -117,71 +128,71 @@ class Constants {
|
|||
static const List<Dependency> _flutterPluginsCommon = [
|
||||
Dependency(
|
||||
name: 'Connectivity Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Device Info Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/device_info_plus/device_info_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/device_info_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Dynamic Color',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/material-foundation/material-dynamic-color-flutter',
|
||||
),
|
||||
Dependency(
|
||||
name: 'fijkplayer (Aves fork)',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/deckerst/fijkplayer',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Display Mode',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/ajinasokan/flutter_displaymode',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Package Info Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/package_info_plus/package_info_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Permission Handler',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Printing',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Screen Brightness',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Shared Preferences',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences',
|
||||
),
|
||||
Dependency(
|
||||
name: 'sqflite',
|
||||
license: 'BSD 2-Clause',
|
||||
license: bsd2,
|
||||
sourceUrl: 'https://github.com/tekartik/sqflite',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Streams Channel (Aves fork)',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/deckerst/aves_streams_channel',
|
||||
),
|
||||
Dependency(
|
||||
name: 'URL Launcher',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher',
|
||||
),
|
||||
|
@ -190,12 +201,12 @@ class Constants {
|
|||
static const List<Dependency> _googleMobileServices = [
|
||||
Dependency(
|
||||
name: 'Google API Availability',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Google Maps for Flutter',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter',
|
||||
),
|
||||
|
@ -204,7 +215,7 @@ class Constants {
|
|||
static const List<Dependency> _huaweiMobileServices = [
|
||||
Dependency(
|
||||
name: 'Huawei Mobile Services (Availability, Map)',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
licenseUrl: 'https://github.com/HMS-Core/hms-flutter-plugin/blob/master/LICENCE',
|
||||
sourceUrl: 'https://github.com/HMS-Core/hms-flutter-plugin',
|
||||
),
|
||||
|
@ -222,7 +233,7 @@ class Constants {
|
|||
..._googleMobileServices,
|
||||
Dependency(
|
||||
name: 'FlutterFire (Core, Crashlytics)',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||
),
|
||||
];
|
||||
|
@ -237,84 +248,84 @@ class Constants {
|
|||
static const List<Dependency> flutterPackages = [
|
||||
Dependency(
|
||||
name: 'Charts',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/google/charts',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Custom rounded rectangle border',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Decorated Icon',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Expansion Tile Card (Aves fork)',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
|
||||
),
|
||||
Dependency(
|
||||
name: 'FlexColorPicker',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/rydmike/flex_color_picker',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Highlight',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/git-touch/highlight',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Map',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/fleaflet/flutter_map',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Markdown',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Staggered Animations',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Material Design Icons Flutter',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Overlay Support',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/boyan01/overlay_support',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Palette Generator',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Panorama (Aves fork)',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/zesage/panorama',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Percent Indicator',
|
||||
license: 'BSD 2-Clause',
|
||||
license: bsd2,
|
||||
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Provider',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/rrousselGit/provider',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Smooth Page Indicator',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/Milad-Akarie/smooth_page_indicator',
|
||||
),
|
||||
];
|
||||
|
@ -322,89 +333,89 @@ class Constants {
|
|||
static const List<Dependency> dartPackages = [
|
||||
Dependency(
|
||||
name: 'Collection',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/dart-lang/collection',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Country Code',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/denixport/dart.country',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Equatable',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/felangel/equatable',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Event Bus',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Fluster',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Lints',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_lints/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_lints',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Get It',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/fluttercommunity/get_it',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Intl',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/dart-lang/intl',
|
||||
),
|
||||
Dependency(
|
||||
name: 'LatLong2',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/jifalops/dart-latlong',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Material Color Utilities',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
licenseUrl: 'https://github.com/material-foundation/material-color-utilities/tree/main/dart/LICENSE',
|
||||
sourceUrl: 'https://github.com/material-foundation/material-color-utilities/tree/main/dart',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Path',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/dart-lang/path',
|
||||
),
|
||||
Dependency(
|
||||
name: 'PDF for Dart and Flutter',
|
||||
license: 'Apache 2.0',
|
||||
license: apache2,
|
||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Proj4dart',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/maRci002/proj4dart',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Stack Trace',
|
||||
license: 'BSD 3-Clause',
|
||||
license: bsd3,
|
||||
sourceUrl: 'https://github.com/dart-lang/stack_trace',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Transparent Image',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/brianegan/transparent_image',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Tuple',
|
||||
license: 'BSD 2-Clause',
|
||||
license: bsd2,
|
||||
sourceUrl: 'https://github.com/google/tuple.dart',
|
||||
),
|
||||
Dependency(
|
||||
name: 'XML',
|
||||
license: 'MIT',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/renggli/dart-xml',
|
||||
),
|
||||
];
|
||||
|
|
|
@ -142,7 +142,6 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
Future<String> _getInfo(BuildContext context) async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
final installer = await androidAppService.getAppInstaller();
|
||||
final flavor = context.read<AppFlavor>().toString().split('.')[1];
|
||||
return [
|
||||
'Aves version: ${packageInfo.version}-$flavor (Build ${packageInfo.buildNumber})',
|
||||
|
@ -153,7 +152,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
|||
'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}',
|
||||
'System locales: ${WidgetsBinding.instance.window.locales.join(', ')}',
|
||||
'Aves locale: ${settings.locale ?? 'system'} -> ${settings.appliedLocale}',
|
||||
'Installer: $installer',
|
||||
'Installer: ${packageInfo.installerStore}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
|
|
@ -22,7 +22,9 @@ class MetadataTab extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _MetadataTabState extends State<MetadataTab> {
|
||||
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader, _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader;
|
||||
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader;
|
||||
late Future<Map> _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader;
|
||||
late Future<String?> _mp4ParserDumpLoader;
|
||||
|
||||
// MediaStore timestamp keys
|
||||
static const secondTimestampKeys = ['date_added', 'date_modified', 'date_expires', 'isPlayed'];
|
||||
|
@ -42,6 +44,7 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
_exifInterfaceMetadataLoader = AndroidDebugService.getExifInterfaceMetadata(entry);
|
||||
_mediaMetadataLoader = AndroidDebugService.getMediaMetadataRetrieverMetadata(entry);
|
||||
_metadataExtractorLoader = AndroidDebugService.getMetadataExtractorSummary(entry);
|
||||
_mp4ParserDumpLoader = AndroidDebugService.getMp4ParserDump(entry);
|
||||
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
|
||||
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
|
||||
setState(() {});
|
||||
|
@ -85,7 +88,7 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
|
||||
Widget builderFromSnapshot(BuildContext context, AsyncSnapshot<Map> snapshot, String title) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
|
||||
return builderFromSnapshotData(context, snapshot.data!, title);
|
||||
}
|
||||
|
||||
|
@ -112,6 +115,27 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
future: _metadataExtractorLoader,
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Metadata Extractor'),
|
||||
),
|
||||
FutureBuilder<String?>(
|
||||
future: _mp4ParserDumpLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
|
||||
final data = snapshot.data?.trim();
|
||||
return AvesExpansionTile(
|
||||
title: 'MP4 Parser',
|
||||
children: [
|
||||
if (data != null && data.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Text(data),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<Map>(
|
||||
future: _pixyMetaLoader,
|
||||
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Pixy Meta'),
|
||||
|
@ -121,7 +145,7 @@ class _MetadataTabState extends State<MetadataTab> {
|
|||
future: _tiffStructureLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: snapshot.data!.entries.map((kv) => builderFromSnapshotData(context, kv.value as Map, 'TIFF ${kv.key}')).toList(),
|
||||
|
|
|
@ -91,6 +91,12 @@ class XmpMMNamespace extends XmpNamespace {
|
|||
XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')),
|
||||
XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')),
|
||||
XmpCardData(RegExp(nsPrefix + r'Ingredients\[(\d+)\]/(.*)')),
|
||||
XmpCardData(RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)')),
|
||||
XmpCardData(
|
||||
RegExp(nsPrefix + r'Pantry\[(\d+)\]/(.*)'),
|
||||
cards: [
|
||||
XmpCardData(RegExp(nsPrefix + r'DerivedFrom/(.*)')),
|
||||
XmpCardData(RegExp(nsPrefix + r'History\[(\d+)\]/(.*)')),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ class PlatformMobileServices extends MobileServices {
|
|||
// cf https://github.com/flutter/flutter/issues/23728
|
||||
// as of google_maps_flutter v2.1.5, Flutter v3.0.1 makes the map hide overlay widgets on API <=22
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
_canRenderMaps = (androidInfo.version.sdkInt ?? 0) >= 21;
|
||||
_canRenderMaps = androidInfo.version.sdkInt >= 21;
|
||||
if (_canRenderMaps) {
|
||||
final mapsImplementation = GoogleMapsFlutterPlatform.instance;
|
||||
if (mapsImplementation is GoogleMapsFlutterAndroid) {
|
||||
|
|
130
pubspec.lock
130
pubspec.lock
|
@ -147,41 +147,13 @@ packages:
|
|||
name: connectivity_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.9"
|
||||
connectivity_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1"
|
||||
connectivity_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.6"
|
||||
version: "3.0.0"
|
||||
connectivity_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
connectivity_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.5"
|
||||
connectivity_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
convert:
|
||||
dependency: transitive
|
||||
|
@ -189,7 +161,7 @@ packages:
|
|||
name: convert
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
version: "3.1.1"
|
||||
country_code:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -238,42 +210,14 @@ packages:
|
|||
name: device_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.5"
|
||||
device_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
device_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
version: "7.0.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
device_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
device_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.0.2"
|
||||
version: "6.0.0"
|
||||
dynamic_color:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -361,14 +305,14 @@ packages:
|
|||
name: firebase_crashlytics
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.8.13"
|
||||
version: "2.9.0"
|
||||
firebase_crashlytics_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_crashlytics_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.19"
|
||||
version: "3.3.0"
|
||||
flex_color_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -559,14 +503,14 @@ packages:
|
|||
name: http_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.1"
|
||||
version: "4.0.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.2.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -715,42 +659,14 @@ packages:
|
|||
name: package_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.3+1"
|
||||
package_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
package_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
version: "3.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
package_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
package_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
version: "2.0.0"
|
||||
palette_generator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -824,21 +740,19 @@ packages:
|
|||
source: hosted
|
||||
version: "10.1.0"
|
||||
permission_handler_android:
|
||||
dependency: "direct overridden"
|
||||
dependency: transitive
|
||||
description:
|
||||
path: permission_handler_android
|
||||
ref: HEAD
|
||||
resolved-ref: "279cf44656272c6b89c73b16097108f3c973c31f"
|
||||
url: "https://github.com/deckerst/flutter-permission-handler"
|
||||
source: git
|
||||
version: "9.0.2+1"
|
||||
name: permission_handler_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "10.2.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "9.0.6"
|
||||
version: "9.0.7"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -852,7 +766,7 @@ packages:
|
|||
name: permission_handler_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
version: "0.1.2"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -922,14 +836,14 @@ packages:
|
|||
name: provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
version: "6.0.4"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "2.1.2"
|
||||
qr:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -992,7 +906,7 @@ packages:
|
|||
name: shared_preferences_android
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
version: "2.0.14"
|
||||
shared_preferences_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1293,7 +1207,7 @@ packages:
|
|||
name: watcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "1.0.2"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1321,7 +1235,7 @@ packages:
|
|||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
version: "3.0.1"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -85,14 +85,6 @@ dependencies:
|
|||
url_launcher:
|
||||
xml:
|
||||
|
||||
dependency_overrides:
|
||||
# TODO TLAD as of 2022/10/09, latest version (v10.1.0) does not support Android 13 storage permissions
|
||||
# `permission_handler_platform_interface` v3.9.0 added support for them but it is not effective
|
||||
permission_handler_android:
|
||||
git:
|
||||
url: https://github.com/deckerst/flutter-permission-handler
|
||||
path: permission_handler_android
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
|
Loading…
Reference in a new issue