Merge branch 'develop'
This commit is contained in:
commit
7bf59106f9
130 changed files with 2095 additions and 1002 deletions
2
.flutter
2
.flutter
|
@ -1 +1 @@
|
||||||
Subproject commit 17025dd88227cd9532c33fa78f5250d548d87e9a
|
Subproject commit d8a9f9a52e5af486f80d932e838ee93861ffd863
|
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
@ -17,7 +17,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
|
10
.github/workflows/quality-check.yml
vendored
10
.github/workflows/quality-check.yml
vendored
|
@ -18,7 +18,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
@ -52,14 +52,14 @@ jobs:
|
||||||
build-mode: manual
|
build-mode: manual
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
# Building relies on the Android Gradle plugin,
|
# Building relies on the Android Gradle plugin,
|
||||||
# which requires a modern Java version (not the default one).
|
# which requires a modern Java version (not the default one).
|
||||||
- name: Set up JDK for Android Gradle plugin
|
- name: Set up JDK for Android Gradle plugin
|
||||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
@ -69,7 +69,7 @@ jobs:
|
||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
uses: github/codeql-action/init@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: ${{ matrix.build-mode }}
|
build-mode: ${{ matrix.build-mode }}
|
||||||
|
@ -83,6 +83,6 @@ jobs:
|
||||||
./flutterw build apk --profile -t lib/main_play.dart --flavor play
|
./flutterw build apk --profile -t lib/main_play.dart --flavor play
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
uses: github/codeql-action/analyze@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||||
with:
|
with:
|
||||||
category: "/language:${{matrix.language}}"
|
category: "/language:${{matrix.language}}"
|
||||||
|
|
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
@ -18,14 +18,14 @@ jobs:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
# Building relies on the Android Gradle plugin,
|
# Building relies on the Android Gradle plugin,
|
||||||
# which requires a modern Java version (not the default one).
|
# which requires a modern Java version (not the default one).
|
||||||
- name: Set up JDK for Android Gradle plugin
|
- name: Set up JDK for Android Gradle plugin
|
||||||
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
|
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4.7.0
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
@ -75,12 +75,12 @@ jobs:
|
||||||
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
AVES_GOOGLE_API_KEY: ${{ secrets.AVES_GOOGLE_API_KEY }}
|
||||||
|
|
||||||
- name: Generate artifact attestation
|
- name: Generate artifact attestation
|
||||||
uses: actions/attest-build-provenance@7668571508540a607bdfd90a87a560489fe372eb # v2.1.0
|
uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
|
||||||
with:
|
with:
|
||||||
subject-path: 'outputs/*'
|
subject-path: 'outputs/*'
|
||||||
|
|
||||||
- name: Create GitHub release
|
- name: Create GitHub release
|
||||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||||
with:
|
with:
|
||||||
artifacts: "outputs/*"
|
artifacts: "outputs/*"
|
||||||
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
body: "[Changelog](https://github.com/${{ github.repository }}/blob/develop/CHANGELOG.md#${{ github.ref_name }})"
|
||||||
|
@ -98,7 +98,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
|
4
.github/workflows/scorecards.yml
vendored
4
.github/workflows/scorecards.yml
vendored
|
@ -31,7 +31,7 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden Runner
|
- name: Harden Runner
|
||||||
uses: step-security/harden-runner@c95a14d0e5bab51a9f56296a4eb0e416910cd350 # v2.10.3
|
uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
|
@ -71,6 +71,6 @@ jobs:
|
||||||
|
|
||||||
# Upload the results to GitHub's code scanning dashboard.
|
# Upload the results to GitHub's code scanning dashboard.
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/upload-sarif@48ab28a6f5dbc2a99bf1e0131198dd8f1df78169 # v3.28.0
|
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|
22
CHANGELOG.md
22
CHANGELOG.md
|
@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## <a id="unreleased"></a>[Unreleased]
|
## <a id="unreleased"></a>[Unreleased]
|
||||||
|
|
||||||
|
## <a id="v1.12.3"></a>[v1.12.3] - 2025-02-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Metadata: edit location via GPX
|
||||||
|
- Metadata: toggle for all types in removal dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Viewer: improved subsampling and filter quality strategy
|
||||||
|
- Collection: ignore moving an item to its current directory
|
||||||
|
- Collection: keep selection when action on several items is interrupted before processing
|
||||||
|
- Collection: preserve favourite status when converting items
|
||||||
|
- upgraded Flutter to stable v3.27.4
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- editing TIFF metadata increasing file size
|
||||||
|
- region decoding for some RAW files
|
||||||
|
- incorrect video size or orientation as reported by Media Store
|
||||||
|
- corrupting image when removing video from motion photo with incorrect metadata
|
||||||
|
|
||||||
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
|
## <a id="v1.12.2"></a>[v1.12.2] - 2025-01-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -36,7 +36,7 @@ android {
|
||||||
namespace 'deckers.thibault.aves'
|
namespace 'deckers.thibault.aves'
|
||||||
compileSdk 35
|
compileSdk 35
|
||||||
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
|
// cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp
|
||||||
ndkVersion '27.0.12077973'
|
ndkVersion '28.0.12916984'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId packageName
|
applicationId packageName
|
||||||
|
@ -151,7 +151,7 @@ repositories {
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1'
|
||||||
|
|
||||||
implementation "androidx.appcompat:appcompat:1.7.0"
|
implementation "androidx.appcompat:appcompat:1.7.0"
|
||||||
implementation 'androidx.core:core-ktx:1.15.0'
|
implementation 'androidx.core:core-ktx:1.15.0'
|
||||||
|
@ -173,13 +173,13 @@ dependencies {
|
||||||
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
// - https://jitpack.io/p/deckerst/Android-TiffBitmapFactory
|
||||||
// - https://jitpack.io/p/deckerst/mp4parser
|
// - https://jitpack.io/p/deckerst/mp4parser
|
||||||
// - https://jitpack.io/p/deckerst/pixymeta-android
|
// - https://jitpack.io/p/deckerst/pixymeta-android
|
||||||
implementation 'com.github.deckerst:Android-TiffBitmapFactory:90c06eebf4'
|
implementation 'com.github.deckerst:Android-TiffBitmapFactory:3ed067f021'
|
||||||
implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd'
|
implementation 'com.github.deckerst.mp4parser:isoparser:d5caf7a3dd'
|
||||||
implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd'
|
implementation 'com.github.deckerst.mp4parser:muxer:d5caf7a3dd'
|
||||||
implementation 'com.github.deckerst:pixymeta-android:9ec7097f17'
|
implementation 'com.github.deckerst:pixymeta-android:71eee77dc4'
|
||||||
implementation project(':exifinterface')
|
implementation project(':exifinterface')
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3'
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.4'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.9.1'
|
kapt 'androidx.annotation:annotation:1.9.1'
|
||||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||||
|
|
|
@ -8,7 +8,6 @@ import android.content.Intent
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
|
@ -16,6 +15,7 @@ import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.SizeF
|
import android.util.SizeF
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
|
import androidx.core.net.toUri
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.DeviceHandler
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
@ -83,7 +83,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density
|
||||||
|
|
||||||
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<FieldMap> {
|
private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List<SizeF> {
|
||||||
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
var sizes: List<SizeF>? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
@ -102,7 +102,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat()))
|
||||||
}
|
}
|
||||||
|
|
||||||
return sizes.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
|
return sizes
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getProps(
|
private suspend fun getProps(
|
||||||
|
@ -116,13 +116,14 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
if (sizesDip.isEmpty()) return null
|
if (sizesDip.isEmpty()) return null
|
||||||
|
|
||||||
val sizeDip = sizesDip.first()
|
val sizeDip = sizesDip.first()
|
||||||
if (sizeDip["widthDip"] == 0 || sizeDip["heightDip"] == 0) return null
|
if (sizeDip.width == 0f || sizeDip.height == 0f) return null
|
||||||
|
|
||||||
|
val sizesDipMap = sizesDip.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) }
|
||||||
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
|
||||||
val params = hashMapOf(
|
val params = hashMapOf(
|
||||||
"widgetId" to widgetId,
|
"widgetId" to widgetId,
|
||||||
"sizesDip" to sizesDip,
|
"sizesDip" to sizesDipMap,
|
||||||
"devicePixelRatio" to getDevicePixelRatio(),
|
"devicePixelRatio" to getDevicePixelRatio(),
|
||||||
"drawEntryImage" to drawEntryImage,
|
"drawEntryImage" to drawEntryImage,
|
||||||
"reuseEntry" to reuseEntry,
|
"reuseEntry" to reuseEntry,
|
||||||
|
@ -259,7 +260,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
|
private fun buildUpdateIntent(context: Context, widgetId: Int): PendingIntent {
|
||||||
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, Uri.parse("widget://$widgetId"), context, HomeWidgetProvider::class.java)
|
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE, "widget://$widgetId".toUri(), context, HomeWidgetProvider::class.java)
|
||||||
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
||||||
|
|
||||||
return PendingIntent.getBroadcast(
|
return PendingIntent.getBroadcast(
|
||||||
|
@ -276,7 +277,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
|
private fun buildOpenAppIntent(context: Context, widgetId: Int): PendingIntent {
|
||||||
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
|
// set a unique URI to prevent the intent (and its extras) from being shared by different widgets
|
||||||
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, Uri.parse("widget://$widgetId"), context, MainActivity::class.java)
|
val intent = Intent(MainActivity.INTENT_ACTION_WIDGET_OPEN, "widget://$widgetId".toUri(), context, MainActivity::class.java)
|
||||||
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
|
.putExtra(MainActivity.EXTRA_KEY_WIDGET_ID, widgetId)
|
||||||
|
|
||||||
return PendingIntent.getActivity(
|
return PendingIntent.getActivity(
|
||||||
|
|
|
@ -69,6 +69,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
// `FlutterFragmentActivity` because of local auth plugin
|
// `FlutterFragmentActivity` because of local auth plugin
|
||||||
open class MainActivity : FlutterFragmentActivity() {
|
open class MainActivity : FlutterFragmentActivity() {
|
||||||
|
@ -442,7 +443,7 @@ open class MainActivity : FlutterFragmentActivity() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, Uri.parse(uriString)) }
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this@MainActivity, uriString.toUri()) }
|
||||||
val intent = Intent().apply {
|
val intent = Intent().apply {
|
||||||
val firstUri = toUri(pickedUris.first())
|
val firstUri = toUri(pickedUris.first())
|
||||||
if (pickedUris.size == 1) {
|
if (pickedUris.size == 1) {
|
||||||
|
|
|
@ -5,7 +5,15 @@ import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import app.loup.streams_channel.StreamsChannel
|
import app.loup.streams_channel.StreamsChannel
|
||||||
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
import deckers.thibault.aves.channel.AvesByteSendingMethodCodec
|
||||||
import deckers.thibault.aves.channel.calls.*
|
import deckers.thibault.aves.channel.calls.AccessibilityHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.DeviceHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.EmbeddedDataHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaSessionHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MediaStoreHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.MetadataFetchHandler
|
||||||
|
import deckers.thibault.aves.channel.calls.StorageHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
import deckers.thibault.aves.channel.calls.window.ServiceWindowHandler
|
||||||
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
import deckers.thibault.aves.channel.calls.window.WindowHandler
|
||||||
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler
|
||||||
|
|
|
@ -16,8 +16,12 @@ import deckers.thibault.aves.utils.FlutterUtils
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import java.util.*
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import java.util.Locale
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
|
@ -7,6 +7,7 @@ import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class WallpaperActivity : MainActivity() {
|
class WallpaperActivity : MainActivity() {
|
||||||
private var originalIntent: String? = null
|
private var originalIntent: String? = null
|
||||||
|
@ -39,7 +40,7 @@ class WallpaperActivity : MainActivity() {
|
||||||
if (originalIntent != null) {
|
if (originalIntent != null) {
|
||||||
val pickedUris = call.argument<List<String>>("uris")
|
val pickedUris = call.argument<List<String>>("uris")
|
||||||
if (!pickedUris.isNullOrEmpty()) {
|
if (!pickedUris.isNullOrEmpty()) {
|
||||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, uriString.toUri()) }
|
||||||
onNewIntent(Intent().apply {
|
onNewIntent(Intent().apply {
|
||||||
action = originalIntent
|
action = originalIntent
|
||||||
data = toUri(pickedUris.first())
|
data = toUri(pickedUris.first())
|
||||||
|
|
|
@ -19,6 +19,7 @@ import androidx.core.content.FileProvider
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
@ -192,7 +193,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val label = call.argument<String>("label")
|
val label = call.argument<String>("label")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("copyToClipboard-args", "missing arguments", null)
|
result.error("copyToClipboard-args", "missing arguments", null)
|
||||||
|
@ -219,7 +220,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val forceChooser = call.argument<Boolean>("forceChooser")
|
val forceChooser = call.argument<Boolean>("forceChooser")
|
||||||
if (uri == null || forceChooser == null) {
|
if (uri == null || forceChooser == null) {
|
||||||
|
@ -236,7 +237,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
|
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||||
if (geoUri == null) {
|
if (geoUri == null) {
|
||||||
result.error("openMap-args", "missing arguments", null)
|
result.error("openMap-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -250,7 +251,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val title = call.argument<String>("title")
|
val title = call.argument<String>("title")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("setAs-args", "missing arguments", null)
|
result.error("setAs-args", "missing arguments", null)
|
||||||
|
@ -273,7 +274,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, Uri.parse(it)) })
|
val uriList = ArrayList(urisByMimeType.values.flatten().mapNotNull { getShareableUri(context, it.toUri()) })
|
||||||
val mimeTypes = urisByMimeType.keys.toTypedArray()
|
val mimeTypes = urisByMimeType.keys.toTypedArray()
|
||||||
|
|
||||||
// simplify share intent for a single item, as some apps can handle one item but not more
|
// simplify share intent for a single item, as some apps can handle one item but not more
|
||||||
|
@ -366,8 +367,8 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
||||||
// route dependent arguments
|
// route dependent arguments
|
||||||
val filters = call.argument<List<String>>("filters")
|
val filters = call.argument<List<String>>("filters")
|
||||||
val explorerPath = call.argument<String>("path")
|
val explorerPath = call.argument<String>("path")
|
||||||
val viewUri = call.argument<String>("viewUri")?.let { Uri.parse(it) }
|
val viewUri = call.argument<String>("viewUri")?.toUri()
|
||||||
val geoUri = call.argument<String>("geoUri")?.let { Uri.parse(it) }
|
val geoUri = call.argument<String>("geoUri")?.toUri()
|
||||||
|
|
||||||
if (label == null || route == null) {
|
if (label == null || route == null) {
|
||||||
result.error("pin-args", "missing arguments", null)
|
result.error("pin-args", "missing arguments", null)
|
||||||
|
|
|
@ -12,7 +12,7 @@ import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import androidx.core.net.toUri
|
||||||
import com.drew.metadata.file.FileTypeDirectory
|
import com.drew.metadata.file.FileTypeDirectory
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
|
@ -44,6 +44,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import org.mp4parser.IsoFile
|
import org.mp4parser.IsoFile
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
@ -127,7 +128,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
result.error("getBitmapDecoderInfo-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -156,7 +157,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentResolverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
result.error("getContentResolverMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -212,7 +213,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getExifInterfaceMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
result.error("getExifInterfaceMetadata-args", "missing arguments", null)
|
||||||
|
@ -239,7 +240,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMediaMetadataRetrieverMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
result.error("getMediaMetadataRetrieverMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -264,7 +265,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMetadataExtractorSummary(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
result.error("getMetadataExtractorSummary-args", "missing arguments", null)
|
||||||
|
@ -308,7 +309,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMp4ParserDump(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getMp4ParserDump-args", "missing arguments", null)
|
result.error("getMp4ParserDump-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -338,7 +339,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getPixyMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getPixyMetadata-args", "missing arguments", null)
|
result.error("getPixyMetadata-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -359,7 +360,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
private fun getTiffStructure(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getTiffStructure-args", "missing arguments", null)
|
result.error("getTiffStructure-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -4,14 +4,15 @@ import android.app.LocaleConfig
|
||||||
import android.app.LocaleManager
|
import android.app.LocaleManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.location.Geocoder
|
import android.location.Geocoder
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.LocaleList
|
import android.os.LocaleList
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -24,7 +25,6 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
@ -62,10 +62,17 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
"isDynamicColorAvailable" to DynamicColors.isDynamicColorAvailable(),
|
||||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||||
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
"supportEdgeToEdgeUIMode" to (sdkInt >= Build.VERSION_CODES.Q),
|
||||||
|
"supportPictureInPicture" to supportPictureInPicture(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun supportPictureInPicture(): Boolean {
|
||||||
|
// minimum version for `PictureInPictureParams.Builder#setAutoEnterEnabled`
|
||||||
|
val supportPipOnLeave = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
|
return supportPipOnLeave && context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
fun toMap(locale: Locale): FieldMap = hashMapOf(
|
fun toMap(locale: Locale): FieldMap = hashMapOf(
|
||||||
"language" to locale.language,
|
"language" to locale.language,
|
||||||
|
@ -130,7 +137,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, Uri.parse("package:${context.packageName}"))
|
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA, "package:${context.packageName}".toUri())
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.exifinterface.media.ExifInterface
|
import androidx.exifinterface.media.ExifInterface
|
||||||
import com.adobe.internal.xmp.XMPException
|
import com.adobe.internal.xmp.XMPException
|
||||||
import com.adobe.internal.xmp.XMPUtils
|
import com.adobe.internal.xmp.XMPUtils
|
||||||
|
@ -18,6 +18,7 @@ import deckers.thibault.aves.metadata.xmp.GoogleDeviceContainer
|
||||||
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
import deckers.thibault.aves.metadata.xmp.GoogleXMP
|
||||||
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
import deckers.thibault.aves.metadata.xmp.XMP.getSafeStructField
|
||||||
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
import deckers.thibault.aves.metadata.xmp.XMPPropName
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.provider.ImageProvider
|
import deckers.thibault.aves.model.provider.ImageProvider
|
||||||
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
||||||
|
@ -59,7 +60,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun getExifThumbnails(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getExifThumbnails-args", "missing arguments", null)
|
result.error("getExifThumbnails-args", "missing arguments", null)
|
||||||
|
@ -88,7 +89,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractGoogleDeviceItem(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val dataUri = call.argument<String>("dataUri")
|
val dataUri = call.argument<String>("dataUri")
|
||||||
|
@ -143,7 +144,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractJpegMpfItem(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val id = call.argument<Int>("id")
|
val id = call.argument<Int>("id")
|
||||||
|
@ -177,7 +178,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractMotionPhotoImage(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||||
|
@ -185,7 +186,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
val imageSizeBytes = sizeBytes - videoSizeBytes
|
val imageSizeBytes = sizeBytes - videoSizeBytes
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
|
||||||
|
@ -198,7 +199,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractMotionPhotoVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null) {
|
if (mimeType == null || uri == null || sizeBytes == null) {
|
||||||
|
@ -206,7 +207,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
val videoStartOffset = sizeBytes - videoSizeBytes
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
input.skip(videoStartOffset)
|
input.skip(videoStartOffset)
|
||||||
|
@ -219,7 +220,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractVideoEmbeddedPicture(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
result.error("extractVideoEmbeddedPicture-args", "missing arguments", null)
|
||||||
|
@ -251,7 +252,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val displayName = call.argument<String>("displayName")
|
val displayName = call.argument<String>("displayName")
|
||||||
val dataProp = call.argument<List<Any>>("propPath")
|
val dataProp = call.argument<List<Any>>("propPath")
|
||||||
|
@ -329,8 +330,8 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
FileProvider.getUriForFile(context, authority, targetFile)
|
FileProvider.getUriForFile(context, authority, targetFile)
|
||||||
}
|
}
|
||||||
val resultFields: FieldMap = hashMapOf(
|
val resultFields: FieldMap = hashMapOf(
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"mimeType" to mimeType,
|
EntryFields.MIME_TYPE to mimeType,
|
||||||
)
|
)
|
||||||
if (isImage(mimeType) || isVideo(mimeType)) {
|
if (isImage(mimeType) || isVideo(mimeType)) {
|
||||||
val provider = getProvider(context, uri)
|
val provider = getProvider(context, uri)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -44,7 +44,7 @@ class MediaEditHandler(private val contextWrapper: ContextWrapper) : MethodCallH
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun captureFrame(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val desiredName = call.argument<String>("desiredName")
|
val desiredName = call.argument<String>("desiredName")
|
||||||
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
val exifFields = call.argument<FieldMap>("exif") ?: HashMap()
|
||||||
val bytes = call.argument<ByteArray>("bytes")
|
val bytes = call.argument<ByteArray>("bytes")
|
||||||
|
|
|
@ -2,7 +2,7 @@ package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||||
|
@ -68,7 +68,7 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun getRegion(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val pageId = call.argument<Int>("pageId")
|
val pageId = call.argument<Int>("pageId")
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -28,7 +28,7 @@ class MediaFetchObjectHandler(private val context: Context) : MethodCallHandler
|
||||||
|
|
||||||
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
private fun getEntry(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
val mimeType = call.argument<String>("mimeType") // MIME type is optional
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
val allowUnsized = call.argument<Boolean>("allowUnsized") ?: false
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
result.error("getEntry-args", "missing arguments", null)
|
result.error("getEntry-args", "missing arguments", null)
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.*
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.media.session.MediaButtonReceiver
|
import androidx.media.session.MediaButtonReceiver
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||||
|
@ -59,7 +63,7 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
|
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val title = call.argument<String>("title") ?: uri?.toString()
|
val title = call.argument<String>("title") ?: uri?.toString()
|
||||||
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
val durationMillis = call.argument<Number>("durationMillis")?.toLong()
|
||||||
val stateString = call.argument<String>("state")
|
val stateString = call.argument<String>("state")
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package deckers.thibault.aves.channel.calls
|
package deckers.thibault.aves.channel.calls
|
||||||
|
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
import deckers.thibault.aves.metadata.Mp4TooLargeException
|
||||||
import deckers.thibault.aves.model.ExifOrientationOp
|
import deckers.thibault.aves.model.ExifOrientationOp
|
||||||
|
@ -54,7 +54,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -82,7 +82,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -109,7 +109,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -134,7 +134,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
@ -160,7 +160,7 @@ class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCa
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (entryMap["uri"] as String?)?.toUri()
|
||||||
val path = entryMap["path"] as String?
|
val path = entryMap["path"] as String?
|
||||||
val mimeType = entryMap["mimeType"] as String?
|
val mimeType = entryMap["mimeType"] as String?
|
||||||
if (uri == null || path == null || mimeType == null) {
|
if (uri == null || path == null || mimeType == null) {
|
||||||
|
|
|
@ -107,6 +107,7 @@ import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
@ -131,7 +132,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getAllMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getAllMetadata-args", "missing arguments", null)
|
result.error("getAllMetadata-args", "missing arguments", null)
|
||||||
|
@ -516,7 +517,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// - XMP / MicrosoftPhoto:Rating
|
// - XMP / MicrosoftPhoto:Rating
|
||||||
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getCatalogMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val path = call.argument<String>("path")
|
val path = call.argument<String>("path")
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
|
@ -869,7 +870,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
private fun getOverlayMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val fields = call.argument<List<String>>("fields")
|
val fields = call.argument<List<String>>("fields")
|
||||||
if (mimeType == null || uri == null || fields == null) {
|
if (mimeType == null || uri == null || fields == null) {
|
||||||
|
@ -1000,7 +1001,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getGeoTiffInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
result.error("getGeoTiffInfo-args", "missing arguments", null)
|
||||||
|
@ -1041,7 +1042,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getMultiPageInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
val isMotionPhoto = call.argument<Boolean>("isMotionPhoto")
|
||||||
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
if (mimeType == null || uri == null || sizeBytes == null || isMotionPhoto == null) {
|
||||||
|
@ -1068,7 +1069,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
private fun getPanoramaInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getPanoramaInfo-args", "missing arguments", null)
|
result.error("getPanoramaInfo-args", "missing arguments", null)
|
||||||
|
@ -1120,7 +1121,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
private fun getIptc(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getIptc-args", "missing arguments", null)
|
result.error("getIptc-args", "missing arguments", null)
|
||||||
return
|
return
|
||||||
|
@ -1146,7 +1147,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
// return an empty list if there is no XMP
|
// return an empty list if there is no XMP
|
||||||
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
private fun getXmp(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
if (mimeType == null || uri == null) {
|
if (mimeType == null || uri == null) {
|
||||||
result.error("getXmp-args", "missing arguments", null)
|
result.error("getXmp-args", "missing arguments", null)
|
||||||
|
@ -1218,7 +1219,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val prop = call.argument<String>("prop")
|
val prop = call.argument<String>("prop")
|
||||||
if (mimeType == null || uri == null || prop == null) {
|
if (mimeType == null || uri == null || prop == null) {
|
||||||
result.error("getContentPropValue-args", "missing arguments", null)
|
result.error("getContentPropValue-args", "missing arguments", null)
|
||||||
|
@ -1235,7 +1236,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
private fun getDate(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val field = call.argument<String>("field")
|
val field = call.argument<String>("field")
|
||||||
if (mimeType == null || uri == null || field == null) {
|
if (mimeType == null || uri == null || field == null) {
|
||||||
|
@ -1304,7 +1305,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
|
||||||
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
private fun getFields(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val mimeType = call.argument<String>("mimeType")
|
val mimeType = call.argument<String>("mimeType")
|
||||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
val uri = call.argument<String>("uri")?.toUri()
|
||||||
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
|
||||||
val fields = call.argument<List<String>>("fields")
|
val fields = call.argument<List<String>>("fields")
|
||||||
if (mimeType == null || uri == null || fields == null) {
|
if (mimeType == null || uri == null || fields == null) {
|
||||||
|
|
|
@ -6,14 +6,14 @@ import android.graphics.BitmapFactory
|
||||||
import android.graphics.BitmapRegionDecoder
|
import android.graphics.BitmapRegionDecoder
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
import deckers.thibault.aves.utils.BitmapRegionDecoderCompat
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
|
import deckers.thibault.aves.utils.BitmapUtils.ARGB_8888_BYTE_SIZE
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
import deckers.thibault.aves.utils.MathUtils
|
import deckers.thibault.aves.utils.MathUtils
|
||||||
import deckers.thibault.aves.utils.MemoryUtils
|
import deckers.thibault.aves.utils.MemoryUtils
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -30,12 +30,7 @@ class RegionFetcher internal constructor(
|
||||||
) {
|
) {
|
||||||
private var lastDecoderRef: LastDecoderRef? = null
|
private var lastDecoderRef: LastDecoderRef? = null
|
||||||
|
|
||||||
private val pageTempUris = HashMap<Pair<Uri, Int>, Uri>()
|
private val exportUris = HashMap<Pair<Uri, Int?>, Uri>()
|
||||||
|
|
||||||
private val multiTrackGlideOptions = RequestOptions()
|
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
|
|
||||||
suspend fun fetch(
|
suspend fun fetch(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
|
@ -45,25 +40,27 @@ class RegionFetcher internal constructor(
|
||||||
regionRect: Rect,
|
regionRect: Rect,
|
||||||
imageWidth: Int,
|
imageWidth: Int,
|
||||||
imageHeight: Int,
|
imageHeight: Int,
|
||||||
|
requestKey: Pair<Uri, Int?> = Pair(uri, pageId),
|
||||||
result: MethodChannel.Result,
|
result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
val id = Pair(uri, pageId)
|
// use JPEG export for requested page
|
||||||
fetch(
|
fetch(
|
||||||
uri = pageTempUris.getOrPut(id) { createJpegForPage(uri, mimeType, pageId) },
|
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
||||||
mimeType = MimeTypes.JPEG,
|
mimeType = MimeTypes.JPEG,
|
||||||
pageId = null,
|
pageId = null,
|
||||||
sampleSize = sampleSize,
|
sampleSize = sampleSize,
|
||||||
regionRect = regionRect,
|
regionRect = regionRect,
|
||||||
imageWidth = imageWidth,
|
imageWidth = imageWidth,
|
||||||
imageHeight = imageHeight,
|
imageHeight = imageHeight,
|
||||||
|
requestKey = requestKey,
|
||||||
result = result,
|
result = result,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentDecoderRef = lastDecoderRef
|
var currentDecoderRef = lastDecoderRef
|
||||||
if (currentDecoderRef != null && currentDecoderRef.uri != uri) {
|
if (currentDecoderRef != null && currentDecoderRef.requestKey != requestKey) {
|
||||||
currentDecoderRef = null
|
currentDecoderRef = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -76,7 +73,7 @@ class RegionFetcher internal constructor(
|
||||||
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
result.error("fetch-read-null", "failed to open file for mimeType=$mimeType uri=$uri regionRect=$regionRect", null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentDecoderRef = LastDecoderRef(uri, newDecoder)
|
currentDecoderRef = LastDecoderRef(requestKey, newDecoder)
|
||||||
}
|
}
|
||||||
val decoder = currentDecoderRef.decoder
|
val decoder = currentDecoderRef.decoder
|
||||||
lastDecoderRef = currentDecoderRef
|
lastDecoderRef = currentDecoderRef
|
||||||
|
@ -119,16 +116,35 @@ class RegionFetcher internal constructor(
|
||||||
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
result.error("fetch-null", "failed to decode region for uri=$uri regionRect=$regionRect", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (mimeType != MimeTypes.JPEG) {
|
||||||
|
// retry with JPEG export on failure,
|
||||||
|
// as some formats are not fully supported by `BitmapRegionDecoder`
|
||||||
|
fetch(
|
||||||
|
uri = exportUris.getOrPut(requestKey) { createTemporaryJpegExport(uri, mimeType, pageId) },
|
||||||
|
mimeType = MimeTypes.JPEG,
|
||||||
|
pageId = null,
|
||||||
|
sampleSize = sampleSize,
|
||||||
|
regionRect = regionRect,
|
||||||
|
imageWidth = imageWidth,
|
||||||
|
imageHeight = imageHeight,
|
||||||
|
requestKey = requestKey,
|
||||||
|
result = result,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
result.error("fetch-read-exception", "failed to initialize region decoder for uri=$uri regionRect=$regionRect", e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createJpegForPage(sourceUri: Uri, mimeType: String, pageId: Int): Uri {
|
private fun createTemporaryJpegExport(uri: Uri, mimeType: String, pageId: Int?): Uri {
|
||||||
|
Log.d(LOG_TAG, "create JPEG export for uri=$uri mimeType=$mimeType pageId=$pageId")
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(multiTrackGlideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(MultiPageImage(context, sourceUri, mimeType, pageId))
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
||||||
.submit()
|
.submit()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val bitmap = target.get()
|
val bitmap = target.get()
|
||||||
val tempFile = StorageUtils.createTempFile(context).apply {
|
val tempFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
@ -143,7 +159,11 @@ class RegionFetcher internal constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class LastDecoderRef(
|
private data class LastDecoderRef(
|
||||||
val uri: Uri,
|
val requestKey: Pair<Uri, Int?>,
|
||||||
val decoder: BitmapRegionDecoder,
|
val decoder: BitmapRegionDecoder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG_TAG = LogUtils.createTag<RegionFetcher>()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,10 +12,8 @@ import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.bumptech.glide.signature.ObjectKey
|
import com.bumptech.glide.signature.ObjectKey
|
||||||
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
import deckers.thibault.aves.decoder.SvgImage
|
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
@ -26,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class ThumbnailFetcher internal constructor(
|
class ThumbnailFetcher internal constructor(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
@ -41,7 +40,7 @@ class ThumbnailFetcher internal constructor(
|
||||||
private val quality: Int,
|
private val quality: Int,
|
||||||
private val result: MethodChannel.Result,
|
private val result: MethodChannel.Result,
|
||||||
) {
|
) {
|
||||||
private val uri: Uri = Uri.parse(uri)
|
private val uri: Uri = uri.toUri()
|
||||||
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
private val width: Int = if (width?.takeIf { it > 0 } != null) width else defaultSize
|
||||||
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
private val height: Int = if (height?.takeIf { it > 0 } != null) height else defaultSize
|
||||||
private val svgFetch = mimeType == SVG
|
private val svgFetch = mimeType == SVG
|
||||||
|
@ -122,27 +121,15 @@ class ThumbnailFetcher internal constructor(
|
||||||
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
|
.format(if (quality == 100) DecodeFormat.PREFER_ARGB_8888 else DecodeFormat.PREFER_RGB_565)
|
||||||
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
|
.signature(ObjectKey("$dateModifiedSecs-$rotationDegrees-$isFlipped-$width-$pageId"))
|
||||||
.override(width, height)
|
.override(width, height)
|
||||||
|
if (isVideo(mimeType)) {
|
||||||
val target = if (isVideo(mimeType)) {
|
|
||||||
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
options = options.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||||
Glide.with(context)
|
}
|
||||||
|
|
||||||
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(options)
|
.apply(options)
|
||||||
.load(VideoThumbnail(context, uri))
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId))
|
||||||
.submit(width, height)
|
.submit(width, height)
|
||||||
} else {
|
|
||||||
val model: Any = when {
|
|
||||||
svgFetch -> SvgImage(context, uri)
|
|
||||||
tiffFetch -> TiffImage(context, uri, pageId)
|
|
||||||
multiPageFetch -> MultiPageImage(context, uri, mimeType, pageId)
|
|
||||||
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
|
|
||||||
}
|
|
||||||
Glide.with(context)
|
|
||||||
.asBitmap()
|
|
||||||
.apply(options)
|
|
||||||
.load(model)
|
|
||||||
.submit(width, height)
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
var bitmap = target.get()
|
var bitmap = target.get()
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
import deckers.thibault.aves.PendingStorageAccessResultHandler
|
||||||
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
import deckers.thibault.aves.channel.calls.AppAdapterHandler
|
||||||
|
@ -71,7 +72,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestMediaFileAccess() {
|
private fun requestMediaFileAccess() {
|
||||||
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null }
|
val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) it.toUri() else null }
|
||||||
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
|
val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null }
|
||||||
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
if (uris.isNullOrEmpty() || mimeTypes == null || mimeTypes.size != uris.size) {
|
||||||
error("requestMediaFileAccess-args", "missing arguments", null)
|
error("requestMediaFileAccess-args", "missing arguments", null)
|
||||||
|
@ -190,7 +191,7 @@ class ActivityResultStreamHandler(private val activity: Activity, arguments: Any
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_EDIT)
|
val intent = Intent(Intent.ACTION_EDIT)
|
||||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
.setDataAndType(AppAdapterHandler.getShareableUri(activity, Uri.parse(uri)), mimeType)
|
.setDataAndType(AppAdapterHandler.getShareableUri(activity, uri.toUri()), mimeType)
|
||||||
|
|
||||||
if (intent.resolveActivity(activity.packageManager) == null) {
|
if (intent.resolveActivity(activity.packageManager) == null) {
|
||||||
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
error("edit-resolve", "cannot resolve activity for this intent for uri=$uri mimeType=$mimeType", null)
|
||||||
|
|
|
@ -5,13 +5,9 @@ import android.net.Uri
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
|
||||||
import deckers.thibault.aves.decoder.VideoThumbnail
|
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
|
||||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -85,7 +81,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
val mimeType = arguments["mimeType"] as String?
|
val mimeType = arguments["mimeType"] as String?
|
||||||
val uri = (arguments["uri"] as String?)?.let { Uri.parse(it) }
|
val uri = (arguments["uri"] as String?)?.toUri()
|
||||||
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
|
val sizeBytes = (arguments["sizeBytes"] as Number?)?.toLong()
|
||||||
val rotationDegrees = arguments["rotationDegrees"] as Int
|
val rotationDegrees = arguments["rotationDegrees"] as Int
|
||||||
val isFlipped = arguments["isFlipped"] as Boolean
|
val isFlipped = arguments["isFlipped"] as Boolean
|
||||||
|
@ -130,18 +126,10 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
rotationDegrees: Int,
|
rotationDegrees: Int,
|
||||||
isFlipped: Boolean,
|
isFlipped: Boolean,
|
||||||
) {
|
) {
|
||||||
val model: Any = if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
|
||||||
MultiPageImage(context, uri, mimeType, pageId)
|
|
||||||
} else if (mimeType == MimeTypes.TIFF) {
|
|
||||||
TiffImage(context, uri, pageId)
|
|
||||||
} else {
|
|
||||||
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(model)
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, pageId, sizeBytes))
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
|
@ -159,7 +147,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
error("streamImage-image-decode-null", "failed to get image for mimeType=$mimeType uri=$uri", null)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri model=$model", toErrorDetails(e))
|
error("streamImage-image-decode-exception", "failed to get image for mimeType=$mimeType uri=$uri", toErrorDetails(e))
|
||||||
} finally {
|
} finally {
|
||||||
Glide.with(context).clear(target)
|
Glide.with(context).clear(target)
|
||||||
}
|
}
|
||||||
|
@ -168,8 +156,8 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
private suspend fun streamVideoByGlide(uri: Uri, mimeType: String, sizeBytes: Long?) {
|
||||||
val target = Glide.with(context)
|
val target = Glide.with(context)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(VideoThumbnail(context, uri))
|
.load(AvesAppGlideModule.getModel(context, uri, mimeType, null, sizeBytes))
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
val bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
|
@ -218,11 +206,5 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
|
||||||
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
const val CHANNEL = "deckers.thibault/aves/media_byte_stream"
|
||||||
|
|
||||||
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
private const val BUFFER_SIZE = 2 shl 17 // 256kB
|
||||||
|
|
||||||
// request a fresh image with the highest quality format
|
|
||||||
private val glideOptions = RequestOptions()
|
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
package deckers.thibault.aves.channel.streams
|
package deckers.thibault.aves.channel.streams
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
|
@ -141,7 +141,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
|
||||||
|
|
||||||
// assume same provider for all entries
|
// assume same provider for all entries
|
||||||
val firstEntry = entryMapList.first()
|
val firstEntry = entryMapList.first()
|
||||||
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(activity, it) }
|
val provider = (firstEntry["uri"] as String?)?.toUri()?.let { getProvider(activity, it) }
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
error("convert-provider", "failed to find provider for entry=$firstEntry", null)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
package deckers.thibault.aves.decoder
|
package deckers.thibault.aves.decoder
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.GlideBuilder
|
import com.bumptech.glide.GlideBuilder
|
||||||
import com.bumptech.glide.Registry
|
import com.bumptech.glide.Registry
|
||||||
import com.bumptech.glide.annotation.GlideModule
|
import com.bumptech.glide.annotation.GlideModule
|
||||||
|
import com.bumptech.glide.load.DecodeFormat
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
import com.bumptech.glide.load.ImageHeaderParser
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
import com.bumptech.glide.load.resource.bitmap.ExifInterfaceImageHeaderParser
|
||||||
import com.bumptech.glide.module.AppGlideModule
|
import com.bumptech.glide.module.AppGlideModule
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
|
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||||
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.compatRemoveIf
|
import deckers.thibault.aves.utils.compatRemoveIf
|
||||||
|
|
||||||
@GlideModule
|
@GlideModule
|
||||||
|
@ -25,4 +32,26 @@ class AvesAppGlideModule : AppGlideModule() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isManifestParsingEnabled(): Boolean = false
|
override fun isManifestParsingEnabled(): Boolean = false
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// request a fresh image with the highest quality format
|
||||||
|
val uncachedFullImageOptions = RequestOptions()
|
||||||
|
.format(DecodeFormat.PREFER_ARGB_8888)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.skipMemoryCache(true)
|
||||||
|
|
||||||
|
fun getModel(context: Context, uri: Uri, mimeType: String, pageId: Int?, sizeBytes: Long? = null): Any {
|
||||||
|
return if (pageId != null && MultiPageImage.isSupported(mimeType)) {
|
||||||
|
MultiPageImage(context, uri, mimeType, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.TIFF) {
|
||||||
|
TiffImage(context, uri, pageId)
|
||||||
|
} else if (mimeType == MimeTypes.SVG) {
|
||||||
|
SvgImage(context, uri)
|
||||||
|
} else if (isVideo(mimeType)) {
|
||||||
|
VideoThumbnail(context, uri)
|
||||||
|
} else {
|
||||||
|
StorageUtils.getGlideSafeUri(context, uri, mimeType, sizeBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,6 @@
|
||||||
package deckers.thibault.aves.metadata
|
package deckers.thibault.aves.metadata
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import com.drew.lang.Rational
|
import com.drew.lang.Rational
|
||||||
import com.drew.metadata.Directory
|
import com.drew.metadata.Directory
|
||||||
import com.drew.metadata.exif.ExifDirectoryBase
|
import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
|
@ -19,6 +18,7 @@ import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.floor
|
import kotlin.math.floor
|
||||||
import kotlin.math.roundToLong
|
import kotlin.math.roundToLong
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object ExifInterfaceHelper {
|
object ExifInterfaceHelper {
|
||||||
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
private val LOG_TAG = LogUtils.createTag<ExifInterfaceHelper>()
|
||||||
|
|
|
@ -111,20 +111,25 @@ object MediaMetadataRetrieverHelper {
|
||||||
// format
|
// format
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
|
||||||
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
|
||||||
val bitrate = value.toLongOrNull() ?: 0
|
val bitrate = value.toLongOrNull() ?: 0
|
||||||
if (bitrate > 0) formatBitrate(bitrate) else null
|
if (bitrate > 0) formatBitrate(bitrate) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
|
||||||
val framerate = value.toDoubleOrNull() ?: 0.0
|
val framerate = value.toDoubleOrNull() ?: 0.0
|
||||||
if (framerate > 0.0) "$framerate" else null
|
if (framerate > 0.0) "$framerate" else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
|
||||||
val dateMillis = value.toLongOrNull() ?: 0
|
val dateMillis = value.toLongOrNull() ?: 0
|
||||||
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
MediaFormat.COLOR_RANGE_FULL -> "Full"
|
||||||
|
@ -132,6 +137,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
|
||||||
|
@ -141,6 +147,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
else -> value
|
else -> value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
|
||||||
when (value.toIntOrNull()) {
|
when (value.toIntOrNull()) {
|
||||||
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
|
||||||
|
@ -154,6 +161,7 @@ object MediaMetadataRetrieverHelper {
|
||||||
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
|
||||||
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
|
||||||
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
|
||||||
|
|
||||||
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
|
||||||
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
MediaMetadataRetriever.METADATA_KEY_DATE -> {
|
||||||
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
val dateMillis = Metadata.parseVideoMetadataDate(value)
|
||||||
|
@ -168,4 +176,12 @@ object MediaMetadataRetrieverHelper {
|
||||||
}?.let { save(it) }
|
}?.let { save(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getInteger(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
||||||
|
if (this.containsKey(key)) save(this.getLong(key))
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -9,7 +9,11 @@ import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import deckers.thibault.aves.utils.toByteArray
|
import deckers.thibault.aves.utils.toByteArray
|
||||||
import deckers.thibault.aves.utils.toHex
|
import deckers.thibault.aves.utils.toHex
|
||||||
import org.mp4parser.*
|
import org.mp4parser.BasicContainer
|
||||||
|
import org.mp4parser.Box
|
||||||
|
import org.mp4parser.Container
|
||||||
|
import org.mp4parser.IsoFile
|
||||||
|
import org.mp4parser.PropertyBoxParserImpl
|
||||||
import org.mp4parser.boxes.UnknownBox
|
import org.mp4parser.boxes.UnknownBox
|
||||||
import org.mp4parser.boxes.UserBox
|
import org.mp4parser.boxes.UserBox
|
||||||
import org.mp4parser.boxes.apple.AppleCoverBox
|
import org.mp4parser.boxes.apple.AppleCoverBox
|
||||||
|
@ -17,7 +21,16 @@ import org.mp4parser.boxes.apple.AppleGPSCoordinatesBox
|
||||||
import org.mp4parser.boxes.apple.AppleItemListBox
|
import org.mp4parser.boxes.apple.AppleItemListBox
|
||||||
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
import org.mp4parser.boxes.apple.AppleVariableSignedIntegerBox
|
||||||
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
import org.mp4parser.boxes.apple.Utf8AppleDataBox
|
||||||
import org.mp4parser.boxes.iso14496.part12.*
|
import org.mp4parser.boxes.iso14496.part12.FreeBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.HandlerBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MediaDataBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MetaBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MovieBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.MovieFragmentBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.SampleTableBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.SegmentIndexBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.TrackHeaderBox
|
||||||
|
import org.mp4parser.boxes.iso14496.part12.UserDataBox
|
||||||
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
|
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
|
||||||
import org.mp4parser.support.AbstractBox
|
import org.mp4parser.support.AbstractBox
|
||||||
import org.mp4parser.support.Matrix
|
import org.mp4parser.support.Matrix
|
||||||
|
|
|
@ -15,6 +15,8 @@ import com.drew.metadata.exif.ExifDirectoryBase
|
||||||
import com.drew.metadata.exif.ExifIFD0Directory
|
import com.drew.metadata.exif.ExifIFD0Directory
|
||||||
import com.drew.metadata.xmp.XmpDirectory
|
import com.drew.metadata.xmp.XmpDirectory
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
|
||||||
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
|
||||||
|
import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
|
||||||
|
@ -47,14 +49,6 @@ object MultiPage {
|
||||||
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
|
||||||
|
|
||||||
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
|
fun getHeicTracks(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getLong(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
val tracks = ArrayList<FieldMap>()
|
val tracks = ArrayList<FieldMap>()
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
extractor.setDataSource(context, uri, null)
|
extractor.setDataSource(context, uri, null)
|
||||||
|
@ -250,23 +244,9 @@ object MultiPage {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
|
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList<FieldMap> {
|
||||||
fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getInteger(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
|
|
||||||
if (this.containsKey(key)) save(this.getLong(key))
|
|
||||||
}
|
|
||||||
|
|
||||||
val pages = ArrayList<FieldMap>()
|
val pages = ArrayList<FieldMap>()
|
||||||
val extractor = MediaExtractor()
|
getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
||||||
var pfd: ParcelFileDescriptor? = null
|
getTrailerVideoInfo(context, uri, fileSizeBytes = sizeBytes, videoSizeBytes = videoSizeBytes)?.let { videoInfo ->
|
||||||
try {
|
|
||||||
getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
|
|
||||||
val videoStartOffset = sizeBytes - videoSizeBytes
|
|
||||||
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
|
||||||
pfd?.fileDescriptor?.let { fd ->
|
|
||||||
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
|
|
||||||
// set the original image as the first and default track
|
// set the original image as the first and default track
|
||||||
var pageIndex = 0
|
var pageIndex = 0
|
||||||
pages.add(
|
pages.add(
|
||||||
|
@ -277,43 +257,28 @@ object MultiPage {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
// add video tracks from the appended video
|
// add video tracks from the appended video
|
||||||
if (extractor.trackCount > 0) {
|
videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
||||||
// only consider the first track to represent the appended video
|
|
||||||
val trackIndex = 0
|
|
||||||
try {
|
|
||||||
val format = extractor.getTrackFormat(trackIndex)
|
|
||||||
format.getString(MediaFormat.KEY_MIME)?.let { mime ->
|
|
||||||
if (MimeTypes.isVideo(mime)) {
|
if (MimeTypes.isVideo(mime)) {
|
||||||
val page: FieldMap = hashMapOf(
|
val page: FieldMap = hashMapOf(
|
||||||
KEY_PAGE to pageIndex++,
|
KEY_PAGE to pageIndex++,
|
||||||
KEY_MIME_TYPE to MimeTypes.MP4,
|
KEY_MIME_TYPE to MimeTypes.MP4,
|
||||||
KEY_IS_DEFAULT to false,
|
KEY_IS_DEFAULT to false,
|
||||||
)
|
)
|
||||||
format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
|
||||||
format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
format.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
|
||||||
}
|
}
|
||||||
format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
|
||||||
pages.add(page)
|
pages.add(page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
|
|
||||||
} finally {
|
|
||||||
extractor.release()
|
|
||||||
pfd?.close()
|
|
||||||
}
|
|
||||||
return pages
|
return pages
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
fun getMotionPhotoVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
|
||||||
if (MimeTypes.isHeic(mimeType)) {
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
|
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
|
||||||
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
|
||||||
|
@ -360,6 +325,34 @@ object MultiPage {
|
||||||
return offsetFromEnd
|
return offsetFromEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getTrailerVideoInfo(context: Context, uri: Uri, fileSizeBytes: Long, videoSizeBytes: Long): MediaFormat? {
|
||||||
|
var format: MediaFormat? = null
|
||||||
|
val extractor = MediaExtractor()
|
||||||
|
var pfd: ParcelFileDescriptor? = null
|
||||||
|
try {
|
||||||
|
val videoStartOffset = fileSizeBytes - videoSizeBytes
|
||||||
|
pfd = context.contentResolver.openFileDescriptor(uri, "r")
|
||||||
|
pfd?.fileDescriptor?.let { fd ->
|
||||||
|
extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
|
||||||
|
if (extractor.trackCount > 0) {
|
||||||
|
// only consider the first track to represent the appended video
|
||||||
|
val trackIndex = 0
|
||||||
|
try {
|
||||||
|
format = extractor.getTrackFormat(trackIndex)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
|
||||||
|
} finally {
|
||||||
|
extractor.release()
|
||||||
|
pfd?.close()
|
||||||
|
}
|
||||||
|
return format
|
||||||
|
}
|
||||||
|
|
||||||
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
fun getTiffPages(context: Context, uri: Uri): ArrayList<FieldMap> {
|
||||||
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
|
|
|
@ -26,7 +26,6 @@ import pixy.meta.string.XMLUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
object PixyMetaHelper {
|
object PixyMetaHelper {
|
||||||
fun describe(input: InputStream): HashMap<String, String> {
|
fun describe(input: InputStream): HashMap<String, String> {
|
||||||
|
|
|
@ -3,7 +3,6 @@ package deckers.thibault.aves.metadata
|
||||||
import deckers.thibault.aves.utils.toHex
|
import deckers.thibault.aves.utils.toHex
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
class QuickTimeMetadataBlock(val type: String, val value: String, val language: String)
|
||||||
|
|
||||||
|
|
|
@ -183,7 +183,7 @@ object GoogleXMP {
|
||||||
return offsetFromEnd
|
return offsetFromEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateTrailingVideoOffset(xmp: String, oldOffset: Int, newOffset: Int): String {
|
fun updateTrailingVideoOffset(xmp: String, oldOffset: Number, newOffset: Number): String {
|
||||||
return xmp.replace(
|
return xmp.replace(
|
||||||
// GCamera motion photo
|
// GCamera motion photo
|
||||||
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
|
||||||
|
@ -195,7 +195,6 @@ object GoogleXMP {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
|
||||||
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
|
||||||
GoogleDeviceContainer().apply { findItems(meta) }
|
GoogleDeviceContainer().apply { findItems(meta) }
|
||||||
|
|
|
@ -1,19 +1,20 @@
|
||||||
package deckers.thibault.aves.model
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class AvesEntry(map: FieldMap) {
|
class AvesEntry(map: FieldMap) {
|
||||||
val uri: Uri = Uri.parse(map["uri"] as String) // content or file URI
|
val uri: Uri = (map[EntryFields.URI] as String).toUri() // content or file URI
|
||||||
val path = map["path"] as String? // best effort to get local path
|
val path = map[EntryFields.PATH] as String? // best effort to get local path
|
||||||
val pageId = map["pageId"] as Int? // null means the main entry
|
val pageId = map[EntryFields.PAGE_ID] as Int? // null means the main entry
|
||||||
val mimeType = map["mimeType"] as String
|
val mimeType = map[EntryFields.MIME_TYPE] as String
|
||||||
val width = map["width"] as Int
|
val width = map[EntryFields.WIDTH] as Int
|
||||||
val height = map["height"] as Int
|
val height = map[EntryFields.HEIGHT] as Int
|
||||||
val rotationDegrees = map["rotationDegrees"] as Int
|
val rotationDegrees = map[EntryFields.ROTATION_DEGREES] as Int
|
||||||
val isFlipped = map["isFlipped"] as Boolean
|
val isFlipped = map[EntryFields.IS_FLIPPED] as Boolean
|
||||||
val sizeBytes = toLong(map["sizeBytes"])
|
val sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
||||||
val trashed = map["trashed"] as Boolean
|
val trashed = map[EntryFields.TRASHED] as Boolean
|
||||||
val trashPath = map["trashPath"] as String?
|
val trashPath = map[EntryFields.TRASH_PATH] as String?
|
||||||
|
|
||||||
private val isRotated: Boolean
|
private val isRotated: Boolean
|
||||||
get() = rotationDegrees % 180 == 90
|
get() = rotationDegrees % 180 == 90
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package deckers.thibault.aves.model
|
||||||
|
|
||||||
|
// entry fields exported and imported from/to the platform side
|
||||||
|
// should match `EntryFields` on Dart side
|
||||||
|
object EntryFields {
|
||||||
|
const val ORIGIN = "origin" // int
|
||||||
|
const val URI = "uri" // string
|
||||||
|
const val CONTENT_ID = "contentId" // long
|
||||||
|
const val PATH = "path" // string
|
||||||
|
const val PAGE_ID = "pageId" // int
|
||||||
|
const val SOURCE_MIME_TYPE = "sourceMimeType" // string
|
||||||
|
const val MIME_TYPE = "mimeType" // string
|
||||||
|
|
||||||
|
const val WIDTH = "width" // int
|
||||||
|
const val HEIGHT = "height" // int
|
||||||
|
const val SOURCE_ROTATION_DEGREES = "sourceRotationDegrees" // int
|
||||||
|
const val ROTATION_DEGREES = "rotationDegrees" // int
|
||||||
|
const val IS_FLIPPED = "isFlipped" // boolean
|
||||||
|
|
||||||
|
const val DATE_ADDED_SECS = "dateAddedSecs" // long
|
||||||
|
const val DATE_MODIFIED_SECS = "dateModifiedSecs" // long
|
||||||
|
const val SOURCE_DATE_TAKEN_MILLIS = "sourceDateTakenMillis" // long
|
||||||
|
const val DURATION_MILLIS = "durationMillis" // long
|
||||||
|
|
||||||
|
const val SIZE_BYTES = "sizeBytes" // long
|
||||||
|
const val TRASHED = "trashed" // boolean
|
||||||
|
const val TRASH_PATH = "trashPath" // string
|
||||||
|
const val TITLE = "title" // string
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
class SourceEntry {
|
class SourceEntry {
|
||||||
private val origin: Int
|
private val origin: Int
|
||||||
|
@ -54,19 +55,19 @@ class SourceEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(map: FieldMap) {
|
constructor(map: FieldMap) {
|
||||||
origin = map["origin"] as Int
|
origin = map[EntryFields.ORIGIN] as Int
|
||||||
uri = Uri.parse(map["uri"] as String)
|
uri = (map[EntryFields.URI] as String).toUri()
|
||||||
path = map["path"] as String?
|
path = map[EntryFields.PATH] as String?
|
||||||
sourceMimeType = map["sourceMimeType"] as String
|
sourceMimeType = map[EntryFields.SOURCE_MIME_TYPE] as String
|
||||||
width = map["width"] as Int?
|
width = map[EntryFields.WIDTH] as Int?
|
||||||
height = map["height"] as Int?
|
height = map[EntryFields.HEIGHT] as Int?
|
||||||
sourceRotationDegrees = map["sourceRotationDegrees"] as Int?
|
sourceRotationDegrees = map[EntryFields.SOURCE_ROTATION_DEGREES] as Int?
|
||||||
sizeBytes = toLong(map["sizeBytes"])
|
sizeBytes = toLong(map[EntryFields.SIZE_BYTES])
|
||||||
title = map["title"] as String?
|
title = map[EntryFields.TITLE] as String?
|
||||||
dateAddedSecs = toLong(map["dateAddedSecs"])
|
dateAddedSecs = toLong(map[EntryFields.DATE_ADDED_SECS])
|
||||||
dateModifiedSecs = toLong(map["dateModifiedSecs"])
|
dateModifiedSecs = toLong(map[EntryFields.DATE_MODIFIED_SECS])
|
||||||
sourceDateTakenMillis = toLong(map["sourceDateTakenMillis"])
|
sourceDateTakenMillis = toLong(map[EntryFields.SOURCE_DATE_TAKEN_MILLIS])
|
||||||
durationMillis = toLong(map["durationMillis"])
|
durationMillis = toLong(map[EntryFields.DURATION_MILLIS])
|
||||||
}
|
}
|
||||||
|
|
||||||
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
|
fun initFromFile(path: String, title: String, sizeBytes: Long, dateModifiedSecs: Long) {
|
||||||
|
@ -78,21 +79,21 @@ class SourceEntry {
|
||||||
|
|
||||||
fun toMap(): FieldMap {
|
fun toMap(): FieldMap {
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"origin" to origin,
|
EntryFields.ORIGIN to origin,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"path" to path,
|
EntryFields.PATH to path,
|
||||||
"sourceMimeType" to sourceMimeType,
|
EntryFields.SOURCE_MIME_TYPE to sourceMimeType,
|
||||||
"width" to width,
|
EntryFields.WIDTH to width,
|
||||||
"height" to height,
|
EntryFields.HEIGHT to height,
|
||||||
"sourceRotationDegrees" to (sourceRotationDegrees ?: 0),
|
EntryFields.SOURCE_ROTATION_DEGREES to (sourceRotationDegrees ?: 0),
|
||||||
"sizeBytes" to sizeBytes,
|
EntryFields.SIZE_BYTES to sizeBytes,
|
||||||
"title" to title,
|
EntryFields.TITLE to title,
|
||||||
"dateAddedSecs" to dateAddedSecs,
|
EntryFields.DATE_ADDED_SECS to dateAddedSecs,
|
||||||
"dateModifiedSecs" to dateModifiedSecs,
|
EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
|
||||||
"sourceDateTakenMillis" to sourceDateTakenMillis,
|
EntryFields.SOURCE_DATE_TAKEN_MILLIS to sourceDateTakenMillis,
|
||||||
"durationMillis" to durationMillis,
|
EntryFields.DURATION_MILLIS to durationMillis,
|
||||||
// only for map export
|
// only for map export
|
||||||
"contentId" to contentId,
|
EntryFields.CONTENT_ID to contentId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.content.ContextWrapper
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -88,9 +89,9 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return hashMapOf(
|
return hashMapOf(
|
||||||
"uri" to Uri.fromFile(newFile).toString(),
|
EntryFields.URI to Uri.fromFile(newFile).toString(),
|
||||||
"path" to newFile.path,
|
EntryFields.PATH to newFile.path,
|
||||||
"dateModifiedSecs" to newFile.lastModified() / 1000,
|
EntryFields.DATE_MODIFIED_SECS to newFile.lastModified() / 1000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,8 +99,8 @@ internal class FileImageProvider : ImageProvider() {
|
||||||
try {
|
try {
|
||||||
val file = File(path)
|
val file = File(path)
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
newFields["dateModifiedSecs"] = file.lastModified() / 1000
|
newFields[EntryFields.DATE_MODIFIED_SECS] = file.lastModified() / 1000
|
||||||
newFields["sizeBytes"] = file.length()
|
newFields[EntryFields.SIZE_BYTES] = file.length()
|
||||||
}
|
}
|
||||||
callback.onSuccess(newFields)
|
callback.onSuccess(newFields)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
|
|
@ -11,16 +11,10 @@ import android.net.Uri
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.DecodeFormat
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.FutureTarget
|
import com.bumptech.glide.request.FutureTarget
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat
|
import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.AvesAppGlideModule
|
||||||
import deckers.thibault.aves.decoder.SvgImage
|
|
||||||
import deckers.thibault.aves.decoder.TiffImage
|
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper
|
||||||
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
|
||||||
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
import deckers.thibault.aves.metadata.Metadata.TYPE_EXIF
|
||||||
|
@ -68,6 +62,9 @@ import java.nio.channels.Channels
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
|
|
||||||
abstract class ImageProvider {
|
abstract class ImageProvider {
|
||||||
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, allowUnsized: Boolean, callback: ImageOpCallback) {
|
||||||
|
@ -78,10 +75,10 @@ abstract class ImageProvider {
|
||||||
return if (StorageUtils.isInVault(context, path)) {
|
return if (StorageUtils.isInVault(context, path)) {
|
||||||
val uri = Uri.fromFile(File(path))
|
val uri = Uri.fromFile(File(path))
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
"origin" to SourceEntry.ORIGIN_VAULT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_VAULT,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"contentId" to null,
|
EntryFields.CONTENT_ID to null,
|
||||||
"path" to path,
|
EntryFields.PATH to path,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
MediaStoreImageProvider().scanNewPathByMediaStore(context, path, mimeType)
|
||||||
|
@ -317,27 +314,12 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val model: Any = if (pageId != null && MultiPageImage.isSupported(sourceMimeType)) {
|
|
||||||
MultiPageImage(activity, sourceUri, sourceMimeType, pageId)
|
|
||||||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
|
||||||
TiffImage(activity, sourceUri, pageId)
|
|
||||||
} else if (sourceMimeType == MimeTypes.SVG) {
|
|
||||||
SvgImage(activity, sourceUri)
|
|
||||||
} else {
|
|
||||||
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType, sourceEntry.sizeBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// request a fresh image with the highest quality format
|
|
||||||
val glideOptions = RequestOptions()
|
|
||||||
.format(DecodeFormat.PREFER_ARGB_8888)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
|
||||||
.skipMemoryCache(true)
|
|
||||||
|
|
||||||
target = Glide.with(activity.applicationContext)
|
target = Glide.with(activity.applicationContext)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.apply(glideOptions)
|
.apply(AvesAppGlideModule.uncachedFullImageOptions)
|
||||||
.load(model)
|
.load(AvesAppGlideModule.getModel(activity, sourceUri, sourceMimeType, pageId, sourceEntry.sizeBytes))
|
||||||
.submit(targetWidthPx, targetHeightPx)
|
.submit(targetWidthPx, targetHeightPx)
|
||||||
|
|
||||||
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
var bitmap = withContext(Dispatchers.IO) { target.get() }
|
||||||
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
if (MimeTypes.needRotationAfterGlide(sourceMimeType, pageId)) {
|
||||||
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
|
||||||
|
@ -380,7 +362,7 @@ abstract class ImageProvider {
|
||||||
)
|
)
|
||||||
|
|
||||||
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
val newFields = scanNewPath(activity, targetPath, exportMimeType)
|
||||||
val targetUri = Uri.parse(newFields["uri"] as String)
|
val targetUri = (newFields[EntryFields.URI] as String).toUri()
|
||||||
if (writeMetadata) {
|
if (writeMetadata) {
|
||||||
copyMetadata(
|
copyMetadata(
|
||||||
context = activity,
|
context = activity,
|
||||||
|
@ -664,19 +646,21 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
var trailerVideoBytes: ByteArray? = null
|
||||||
var videoBytes: ByteArray? = null
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
|
||||||
|
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
|
||||||
try {
|
try {
|
||||||
if (videoSize != null) {
|
if (videoSize != null && isTrailerVideoValid) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSize = (originalFileSize - videoSize).toInt()
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
videoBytes = ByteArray(videoSize)
|
val videoByteSize = videoSize.toInt()
|
||||||
|
trailerVideoBytes = ByteArray(videoByteSize)
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSize)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, imageSize)
|
input.read(imageBytes, 0, imageSize)
|
||||||
input.read(videoBytes, 0, videoSize)
|
input.read(trailerVideoBytes, 0, videoByteSize)
|
||||||
|
|
||||||
// copy only the image to a temporary file for editing
|
// copy only the image to a temporary file for editing
|
||||||
// video will be appended after metadata modification
|
// video will be appended after metadata modification
|
||||||
|
@ -711,15 +695,15 @@ abstract class ImageProvider {
|
||||||
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
|
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoBytes != null) {
|
if (trailerVideoBytes != null) {
|
||||||
// append trailer video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(videoBytes!!)
|
editableFile.appendBytes(trailerVideoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -747,19 +731,21 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
var trailerVideoBytes: ByteArray? = null
|
||||||
var videoBytes: ByteArray? = null
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
|
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
|
||||||
|
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
|
||||||
try {
|
try {
|
||||||
if (videoSize != null) {
|
if (videoSize != null && isTrailerVideoValid) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
val imageSize = (originalFileSize - videoSize).toInt()
|
val imageSize = (originalFileSize - videoSize).toInt()
|
||||||
videoBytes = ByteArray(videoSize)
|
val videoByteSize = videoSize.toInt()
|
||||||
|
trailerVideoBytes = ByteArray(videoByteSize)
|
||||||
|
|
||||||
StorageUtils.openInputStream(context, uri)?.let { input ->
|
StorageUtils.openInputStream(context, uri)?.let { input ->
|
||||||
val imageBytes = ByteArray(imageSize)
|
val imageBytes = ByteArray(imageSize)
|
||||||
input.read(imageBytes, 0, imageSize)
|
input.read(imageBytes, 0, imageSize)
|
||||||
input.read(videoBytes, 0, videoSize)
|
input.read(trailerVideoBytes, 0, videoByteSize)
|
||||||
|
|
||||||
// copy only the image to a temporary file for editing
|
// copy only the image to a temporary file for editing
|
||||||
// video will be appended after metadata modification
|
// video will be appended after metadata modification
|
||||||
|
@ -795,15 +781,15 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoBytes != null) {
|
if (trailerVideoBytes != null) {
|
||||||
// append trailer video, if any
|
// append trailer video, if any
|
||||||
editableFile.appendBytes(videoBytes!!)
|
editableFile.appendBytes(trailerVideoBytes!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
@ -913,7 +899,7 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
editXmpWithPixy(
|
editXmpWithPixy(
|
||||||
|
@ -996,7 +982,7 @@ abstract class ImageProvider {
|
||||||
path: String,
|
path: String,
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
mimeType: String,
|
mimeType: String,
|
||||||
trailerOffset: Int?,
|
trailerOffset: Number?,
|
||||||
editedFile: File,
|
editedFile: File,
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
@ -1011,7 +997,7 @@ abstract class ImageProvider {
|
||||||
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
|
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
|
||||||
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
"We need to edit XMP to adjust trailer video offset by $diff bytes."
|
||||||
)
|
)
|
||||||
val newTrailerOffset = trailerOffset + diff
|
val newTrailerOffset = trailerOffset.toLong() + diff
|
||||||
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
|
||||||
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
|
||||||
})
|
})
|
||||||
|
@ -1276,12 +1262,18 @@ abstract class ImageProvider {
|
||||||
callback: ImageOpCallback,
|
callback: ImageOpCallback,
|
||||||
) {
|
) {
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
|
||||||
if (videoSize == null) {
|
if (videoSize == null) {
|
||||||
callback.onFailure(Exception("failed to get trailer video size"))
|
callback.onFailure(Exception("failed to get trailer video size"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSizeBytes = originalFileSize, videoSizeBytes = videoSize) != null
|
||||||
|
if (!isTrailerVideoValid) {
|
||||||
|
callback.onFailure(Exception("failed to open trailer video with size=$videoSize"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
val inputStream = StorageUtils.openInputStream(context, uri)
|
val inputStream = StorageUtils.openInputStream(context, uri)
|
||||||
|
@ -1321,7 +1313,8 @@ abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
val originalFileSize = File(path).length()
|
val originalFileSize = File(path).length()
|
||||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
|
||||||
|
val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
|
||||||
val editableFile = StorageUtils.createTempFile(context).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
try {
|
try {
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
|
@ -1341,7 +1334,7 @@ abstract class ImageProvider {
|
||||||
// copy the edited temporary file back to the original
|
// copy the edited temporary file back to the original
|
||||||
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
editableFile.transferTo(outputStream(context, mimeType, uri, path))
|
||||||
|
|
||||||
if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editableFile.delete()
|
editableFile.delete()
|
||||||
|
|
|
@ -20,6 +20,7 @@ import com.commonsware.cwac.document.DocumentFileCompat
|
||||||
import deckers.thibault.aves.MainActivity
|
import deckers.thibault.aves.MainActivity
|
||||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
||||||
import deckers.thibault.aves.model.AvesEntry
|
import deckers.thibault.aves.model.AvesEntry
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.NameConflictStrategy
|
import deckers.thibault.aves.model.NameConflictStrategy
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
|
@ -40,7 +41,6 @@ import java.io.FileOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.io.SyncFailedException
|
import java.io.SyncFailedException
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.CompletableFuture
|
import java.util.concurrent.CompletableFuture
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
|
@ -77,7 +77,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val parentCheckDirectory = removeTrailingSeparator(directory)
|
val parentCheckDirectory = removeTrailingSeparator(directory)
|
||||||
handleNew = { entry ->
|
handleNew = { entry ->
|
||||||
// skip entries in subfolders
|
// skip entries in subfolders
|
||||||
val path = entry["path"] as String?
|
val path = entry[EntryFields.PATH] as String?
|
||||||
if (path != null && File(path).parent == parentCheckDirectory) {
|
if (path != null && File(path).parent == parentCheckDirectory) {
|
||||||
handleNewEntry(entry)
|
handleNewEntry(entry)
|
||||||
}
|
}
|
||||||
|
@ -256,20 +256,20 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
||||||
} else {
|
} else {
|
||||||
var entryMap: FieldMap = hashMapOf(
|
var entryMap: FieldMap = hashMapOf(
|
||||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||||
"uri" to itemUri.toString(),
|
EntryFields.URI to itemUri.toString(),
|
||||||
"path" to cursor.getString(pathColumn),
|
EntryFields.PATH to cursor.getString(pathColumn),
|
||||||
"sourceMimeType" to mimeType,
|
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
||||||
"width" to width,
|
EntryFields.WIDTH to width,
|
||||||
"height" to height,
|
EntryFields.HEIGHT to height,
|
||||||
"sourceRotationDegrees" to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
EntryFields.SOURCE_ROTATION_DEGREES to if (orientationColumn != -1) cursor.getInt(orientationColumn) else 0,
|
||||||
"sizeBytes" to cursor.getLong(sizeColumn),
|
EntryFields.SIZE_BYTES to cursor.getLong(sizeColumn),
|
||||||
"dateAddedSecs" to cursor.getInt(dateAddedColumn),
|
EntryFields.DATE_ADDED_SECS to cursor.getInt(dateAddedColumn),
|
||||||
"dateModifiedSecs" to dateModifiedSecs,
|
EntryFields.DATE_MODIFIED_SECS to dateModifiedSecs,
|
||||||
"sourceDateTakenMillis" to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
EntryFields.SOURCE_DATE_TAKEN_MILLIS to if (dateTakenColumn != -1) cursor.getLong(dateTakenColumn) else null,
|
||||||
"durationMillis" to durationMillis,
|
EntryFields.DURATION_MILLIS to durationMillis,
|
||||||
// only for map export
|
// only for map export
|
||||||
"contentId" to id,
|
EntryFields.CONTENT_ID to id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (MimeTypes.isHeic(mimeType)) {
|
if (MimeTypes.isHeic(mimeType)) {
|
||||||
|
@ -285,8 +285,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
if (outWidth > 0 && outHeight > 0) {
|
if (outWidth > 0 && outHeight > 0) {
|
||||||
width = outWidth
|
width = outWidth
|
||||||
height = outHeight
|
height = outHeight
|
||||||
entryMap["width"] = width
|
entryMap[EntryFields.WIDTH] = width
|
||||||
entryMap["height"] = height
|
entryMap[EntryFields.HEIGHT] = height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
@ -598,8 +598,8 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
return if (toBin) {
|
return if (toBin) {
|
||||||
hashMapOf(
|
hashMapOf(
|
||||||
"trashed" to true,
|
EntryFields.TRASHED to true,
|
||||||
"trashPath" to targetPath,
|
EntryFields.TRASH_PATH to targetPath,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
scanNewPath(activity, targetPath, mimeType)
|
scanNewPath(activity, targetPath, mimeType)
|
||||||
|
@ -912,13 +912,13 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
val newFields = hashMapOf<String, Any?>(
|
val newFields = hashMapOf<String, Any?>(
|
||||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"contentId" to uri.tryParseId(),
|
EntryFields.CONTENT_ID to uri.tryParseId(),
|
||||||
"path" to path,
|
EntryFields.PATH to path,
|
||||||
)
|
)
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields["dateAddedSecs"] = cursor.getInt(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED).let { if (it != -1) newFields[EntryFields.DATE_ADDED_SECS] = cursor.getInt(it) }
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields["dateModifiedSecs"] = cursor.getInt(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED).let { if (it != -1) newFields[EntryFields.DATE_MODIFIED_SECS] = cursor.getInt(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
return newFields
|
return newFields
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import android.provider.OpenableColumns
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import deckers.thibault.aves.metadata.Metadata
|
import deckers.thibault.aves.metadata.Metadata
|
||||||
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
import deckers.thibault.aves.metadata.metadataextractor.Helper
|
||||||
|
import deckers.thibault.aves.model.EntryFields
|
||||||
import deckers.thibault.aves.model.FieldMap
|
import deckers.thibault.aves.model.FieldMap
|
||||||
import deckers.thibault.aves.model.SourceEntry
|
import deckers.thibault.aves.model.SourceEntry
|
||||||
import deckers.thibault.aves.utils.LogUtils
|
import deckers.thibault.aves.utils.LogUtils
|
||||||
|
@ -43,9 +44,9 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val fields: FieldMap = hashMapOf(
|
val fields: FieldMap = hashMapOf(
|
||||||
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
EntryFields.ORIGIN to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
||||||
"uri" to uri.toString(),
|
EntryFields.URI to uri.toString(),
|
||||||
"sourceMimeType" to mimeType,
|
EntryFields.SOURCE_MIME_TYPE to mimeType,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
// some providers do not provide the mandatory `OpenableColumns`
|
// some providers do not provide the mandatory `OpenableColumns`
|
||||||
|
@ -53,11 +54,11 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
// e.g. `content://mms/part/[id]` on Android KitKat
|
// e.g. `content://mms/part/[id]` on Android KitKat
|
||||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields["title"] = cursor.getString(it) }
|
cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME).let { if (it != -1) fields[EntryFields.TITLE] = cursor.getString(it) }
|
||||||
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields["sizeBytes"] = cursor.getLong(it) }
|
cursor.getColumnIndex(OpenableColumns.SIZE).let { if (it != -1) fields[EntryFields.SIZE_BYTES] = cursor.getLong(it) }
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields["path"] = cursor.getString(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.DATA).let { if (it != -1) fields[EntryFields.PATH] = cursor.getString(it) }
|
||||||
// mime type fallback if it was not provided and not found via `metadata-extractor`
|
// mime type fallback if it was not provided and not found via `metadata-extractor`
|
||||||
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields["sourceMimeType"] = cursor.getString(it) }
|
cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE).let { if (it != -1 && mimeType == null) fields[EntryFields.SOURCE_MIME_TYPE] = cursor.getString(it) }
|
||||||
cursor.close()
|
cursor.close()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -65,7 +66,7 @@ open class UnknownContentProvider : ImageProvider() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields["sourceMimeType"] == null) {
|
if (fields[EntryFields.SOURCE_MIME_TYPE] == null) {
|
||||||
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
callback.onFailure(Exception("Failed to find MIME type for uri=$uri"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
package deckers.thibault.aves.utils
|
package deckers.thibault.aves.utils
|
||||||
|
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
|
||||||
import deckers.thibault.aves.decoder.MultiPageImage
|
import deckers.thibault.aves.decoder.MultiPageImage
|
||||||
|
import androidx.exifinterface.media.ExifInterfaceFork as ExifInterface
|
||||||
|
|
||||||
object MimeTypes {
|
object MimeTypes {
|
||||||
const val ANY = "*/*"
|
const val ANY = "*/*"
|
||||||
|
|
|
@ -9,7 +9,6 @@ import android.content.pm.PackageManager
|
||||||
import android.media.MediaMetadataRetriever
|
import android.media.MediaMetadataRetriever
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
@ -30,6 +29,8 @@ import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.core.text.isDigitsOnly
|
||||||
|
|
||||||
object StorageUtils {
|
object StorageUtils {
|
||||||
private val LOG_TAG = LogUtils.createTag<StorageUtils>()
|
private val LOG_TAG = LogUtils.createTag<StorageUtils>()
|
||||||
|
@ -81,7 +82,8 @@ object StorageUtils {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val trashDir = File(externalFilesDir, "trash")
|
val trashDir = File(externalFilesDir, "trash")
|
||||||
if (!trashDir.exists() && !trashDir.mkdirs()) {
|
trashDir.mkdirs()
|
||||||
|
if (!trashDir.exists()) {
|
||||||
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -228,7 +230,7 @@ object StorageUtils {
|
||||||
// Device has emulated storage; external storage paths should have userId burned into them.
|
// Device has emulated storage; external storage paths should have userId burned into them.
|
||||||
// /storage/emulated/[0,1,2,...]/
|
// /storage/emulated/[0,1,2,...]/
|
||||||
val path = getPrimaryVolumePath(context)
|
val path = getPrimaryVolumePath(context)
|
||||||
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { TextUtils.isDigitsOnly(it) } ?: ""
|
val rawUserId = path.split(File.separator).lastOrNull(String::isNotEmpty)?.takeIf { it.isDigitsOnly() } ?: ""
|
||||||
if (rawUserId.isEmpty()) {
|
if (rawUserId.isEmpty()) {
|
||||||
paths.add(rawEmulatedStorageTarget)
|
paths.add(rawEmulatedStorageTarget)
|
||||||
} else {
|
} else {
|
||||||
|
@ -499,7 +501,8 @@ object StorageUtils {
|
||||||
parentFile
|
parentFile
|
||||||
} else {
|
} else {
|
||||||
val directory = File(cleanDirPath)
|
val directory = File(cleanDirPath)
|
||||||
if (!directory.exists() && !directory.mkdirs()) {
|
directory.mkdirs()
|
||||||
|
if (!directory.exists()) {
|
||||||
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
Log.e(LOG_TAG, "failed to create directories at path=$cleanDirPath")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@ -636,7 +639,7 @@ object StorageUtils {
|
||||||
|
|
||||||
// strip user info, if any
|
// strip user info, if any
|
||||||
// e.g. `content://0@media/...`
|
// e.g. `content://0@media/...`
|
||||||
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
|
private fun stripMediaUriUserInfo(uri: Uri) = uri.toString().replaceFirst("${uri.userInfo}@", "").toUri()
|
||||||
|
|
||||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||||
val effectiveUri = getOriginalUri(context, uri)
|
val effectiveUri = getOriginalUri(context, uri)
|
||||||
|
@ -712,7 +715,8 @@ object StorageUtils {
|
||||||
|
|
||||||
fun createTempFile(context: Context, extension: String? = null): File {
|
fun createTempFile(context: Context, extension: String? = null): File {
|
||||||
val directory = getTempDirectory(context)
|
val directory = getTempDirectory(context)
|
||||||
if (!directory.exists() && !directory.mkdirs()) {
|
directory.mkdirs()
|
||||||
|
if (!directory.exists()) {
|
||||||
throw IOException("failed to create directories at path=$directory")
|
throw IOException("failed to create directories at path=$directory")
|
||||||
}
|
}
|
||||||
val tempFile = File.createTempFile("aves", extension, directory)
|
val tempFile = File.createTempFile("aves", extension, directory)
|
||||||
|
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="app_name">Aves</string>
|
<string name="app_name">Aves</string>
|
||||||
<string name="analysis_channel_name">Skönnun myndefnis</string>
|
<string name="analysis_channel_name">Skönnun myndefnis</string>
|
||||||
<string name="search_shortcut_short_label">Leita</string>
|
<string name="search_shortcut_short_label">Leita</string>
|
||||||
|
<string name="map_shortcut_short_label">Landakort</string>
|
||||||
</resources>
|
</resources>
|
|
@ -8,4 +8,5 @@
|
||||||
<string name="analysis_notification_default_title">Scanarea suporturilor</string>
|
<string name="analysis_notification_default_title">Scanarea suporturilor</string>
|
||||||
<string name="analysis_notification_action_stop">Stop</string>
|
<string name="analysis_notification_action_stop">Stop</string>
|
||||||
<string name="search_shortcut_short_label">Căutare</string>
|
<string name="search_shortcut_short_label">Căutare</string>
|
||||||
|
<string name="map_shortcut_short_label">Hartă</string>
|
||||||
</resources>
|
</resources>
|
|
@ -13,8 +13,8 @@ buildscript {
|
||||||
dependencies {
|
dependencies {
|
||||||
if (useCrashlytics) {
|
if (useCrashlytics) {
|
||||||
// GMS & Firebase Crashlytics (used by some flavors only)
|
// GMS & Firebase Crashlytics (used by some flavors only)
|
||||||
classpath 'com.google.gms:google-services:4.4.1'
|
classpath 'com.google.gms:google-services:4.4.2'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import static androidx.exifinterface.media.ExifInterfaceUtilsFork.startsWith;
|
||||||
import static java.lang.annotation.ElementType.TYPE_USE;
|
import static java.lang.annotation.ElementType.TYPE_USE;
|
||||||
import static java.nio.ByteOrder.BIG_ENDIAN;
|
import static java.nio.ByteOrder.BIG_ENDIAN;
|
||||||
import static java.nio.ByteOrder.LITTLE_ENDIAN;
|
import static java.nio.ByteOrder.LITTLE_ENDIAN;
|
||||||
|
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.content.res.AssetManager;
|
import android.content.res.AssetManager;
|
||||||
|
@ -54,6 +55,7 @@ import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.DataInput;
|
import java.io.DataInput;
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
import java.io.EOFException;
|
import java.io.EOFException;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileDescriptor;
|
import java.io.FileDescriptor;
|
||||||
|
@ -89,8 +91,9 @@ import java.util.regex.Pattern;
|
||||||
import java.util.zip.CRC32;
|
import java.util.zip.CRC32;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-alpha01' on 2024/11/17
|
* Forked from 'androidx.exifinterface:exifinterface:1.4.0-beta01' on 2025/01/21
|
||||||
* Named differently to let ExifInterface be loaded as subdependency.
|
* Named differently to let ExifInterface be loaded as subdependency.
|
||||||
|
* cf https://maven.google.com/web/index.html?q=exifinterface#androidx.exifinterface:exifinterface
|
||||||
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
|
* cf https://github.com/androidx/androidx/tree/androidx-main/exifinterface/exifinterface/src/main/java/androidx/exifinterface/media
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@ -190,6 +193,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #DATA_UNCOMPRESSED
|
* @see #DATA_UNCOMPRESSED
|
||||||
* @see #DATA_JPEG
|
* @see #DATA_JPEG
|
||||||
*/
|
*/
|
||||||
|
@ -205,6 +209,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #PHOTOMETRIC_INTERPRETATION_RGB
|
* @see #PHOTOMETRIC_INTERPRETATION_RGB
|
||||||
* @see #PHOTOMETRIC_INTERPRETATION_YCBCR
|
* @see #PHOTOMETRIC_INTERPRETATION_YCBCR
|
||||||
*/
|
*/
|
||||||
|
@ -219,6 +224,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #ORIENTATION_NORMAL}</li>
|
* <li>Default = {@link #ORIENTATION_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #ORIENTATION_UNDEFINED
|
* @see #ORIENTATION_UNDEFINED
|
||||||
* @see #ORIENTATION_NORMAL
|
* @see #ORIENTATION_NORMAL
|
||||||
* @see #ORIENTATION_FLIP_HORIZONTAL
|
* @see #ORIENTATION_FLIP_HORIZONTAL
|
||||||
|
@ -254,6 +260,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Count = 1</li>
|
* <li>Count = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #FORMAT_CHUNKY
|
* @see #FORMAT_CHUNKY
|
||||||
* @see #FORMAT_PLANAR
|
* @see #FORMAT_PLANAR
|
||||||
*/
|
*/
|
||||||
|
@ -294,6 +301,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li>
|
* <li>Default = {@link #Y_CB_CR_POSITIONING_CENTERED}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #Y_CB_CR_POSITIONING_CENTERED
|
* @see #Y_CB_CR_POSITIONING_CENTERED
|
||||||
* @see #Y_CB_CR_POSITIONING_CO_SITED
|
* @see #Y_CB_CR_POSITIONING_CO_SITED
|
||||||
*/
|
*/
|
||||||
|
@ -309,6 +317,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 72</li>
|
* <li>Default = 72</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_Y_RESOLUTION
|
* @see #TAG_Y_RESOLUTION
|
||||||
* @see #TAG_RESOLUTION_UNIT
|
* @see #TAG_RESOLUTION_UNIT
|
||||||
*/
|
*/
|
||||||
|
@ -324,6 +333,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 72</li>
|
* <li>Default = 72</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_X_RESOLUTION
|
* @see #TAG_X_RESOLUTION
|
||||||
* @see #TAG_RESOLUTION_UNIT
|
* @see #TAG_RESOLUTION_UNIT
|
||||||
*/
|
*/
|
||||||
|
@ -340,6 +350,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #RESOLUTION_UNIT_INCHES
|
* @see #RESOLUTION_UNIT_INCHES
|
||||||
* @see #RESOLUTION_UNIT_CENTIMETERS
|
* @see #RESOLUTION_UNIT_CENTIMETERS
|
||||||
* @see #TAG_X_RESOLUTION
|
* @see #TAG_X_RESOLUTION
|
||||||
|
@ -365,6 +376,7 @@ public class ExifInterfaceFork {
|
||||||
* <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1)
|
* <p>StripsPerImage = floor(({@link #TAG_IMAGE_LENGTH} + {@link #TAG_ROWS_PER_STRIP} - 1)
|
||||||
* / {@link #TAG_ROWS_PER_STRIP})</p>
|
* / {@link #TAG_ROWS_PER_STRIP})</p>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_ROWS_PER_STRIP
|
* @see #TAG_ROWS_PER_STRIP
|
||||||
* @see #TAG_STRIP_BYTE_COUNTS
|
* @see #TAG_STRIP_BYTE_COUNTS
|
||||||
*/
|
*/
|
||||||
|
@ -381,6 +393,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_STRIP_OFFSETS
|
* @see #TAG_STRIP_OFFSETS
|
||||||
* @see #TAG_STRIP_BYTE_COUNTS
|
* @see #TAG_STRIP_BYTE_COUNTS
|
||||||
*/
|
*/
|
||||||
|
@ -656,6 +669,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Count = 1</li>
|
* <li>Count = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #COLOR_SPACE_S_RGB
|
* @see #COLOR_SPACE_S_RGB
|
||||||
* @see #COLOR_SPACE_UNCALIBRATED
|
* @see #COLOR_SPACE_UNCALIBRATED
|
||||||
*/
|
*/
|
||||||
|
@ -962,6 +976,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li>
|
* <li>Default = {@link #EXPOSURE_PROGRAM_NOT_DEFINED}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #EXPOSURE_PROGRAM_NOT_DEFINED
|
* @see #EXPOSURE_PROGRAM_NOT_DEFINED
|
||||||
* @see #EXPOSURE_PROGRAM_MANUAL
|
* @see #EXPOSURE_PROGRAM_MANUAL
|
||||||
* @see #EXPOSURE_PROGRAM_NORMAL
|
* @see #EXPOSURE_PROGRAM_NORMAL
|
||||||
|
@ -1031,6 +1046,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SENSITIVITY_TYPE_UNKNOWN
|
* @see #SENSITIVITY_TYPE_UNKNOWN
|
||||||
* @see #SENSITIVITY_TYPE_SOS
|
* @see #SENSITIVITY_TYPE_SOS
|
||||||
* @see #SENSITIVITY_TYPE_REI
|
* @see #SENSITIVITY_TYPE_REI
|
||||||
|
@ -1197,6 +1213,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #METERING_MODE_UNKNOWN}</li>
|
* <li>Default = {@link #METERING_MODE_UNKNOWN}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #METERING_MODE_UNKNOWN
|
* @see #METERING_MODE_UNKNOWN
|
||||||
* @see #METERING_MODE_AVERAGE
|
* @see #METERING_MODE_AVERAGE
|
||||||
* @see #METERING_MODE_CENTER_WEIGHT_AVERAGE
|
* @see #METERING_MODE_CENTER_WEIGHT_AVERAGE
|
||||||
|
@ -1217,6 +1234,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li>
|
* <li>Default = {@link #LIGHT_SOURCE_UNKNOWN}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LIGHT_SOURCE_UNKNOWN
|
* @see #LIGHT_SOURCE_UNKNOWN
|
||||||
* @see #LIGHT_SOURCE_DAYLIGHT
|
* @see #LIGHT_SOURCE_DAYLIGHT
|
||||||
* @see #LIGHT_SOURCE_FLUORESCENT
|
* @see #LIGHT_SOURCE_FLUORESCENT
|
||||||
|
@ -1253,6 +1271,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Count = 1</li>
|
* <li>Count = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #FLAG_FLASH_FIRED
|
* @see #FLAG_FLASH_FIRED
|
||||||
* @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED
|
* @see #FLAG_FLASH_RETURN_LIGHT_NOT_DETECTED
|
||||||
* @see #FLAG_FLASH_RETURN_LIGHT_DETECTED
|
* @see #FLAG_FLASH_RETURN_LIGHT_DETECTED
|
||||||
|
@ -1365,6 +1384,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
* <li>Default = {@link #RESOLUTION_UNIT_INCHES}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_RESOLUTION_UNIT
|
* @see #TAG_RESOLUTION_UNIT
|
||||||
* @see #RESOLUTION_UNIT_INCHES
|
* @see #RESOLUTION_UNIT_INCHES
|
||||||
* @see #RESOLUTION_UNIT_CENTIMETERS
|
* @see #RESOLUTION_UNIT_CENTIMETERS
|
||||||
|
@ -1407,6 +1427,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SENSOR_TYPE_NOT_DEFINED
|
* @see #SENSOR_TYPE_NOT_DEFINED
|
||||||
* @see #SENSOR_TYPE_ONE_CHIP
|
* @see #SENSOR_TYPE_ONE_CHIP
|
||||||
* @see #SENSOR_TYPE_TWO_CHIP
|
* @see #SENSOR_TYPE_TWO_CHIP
|
||||||
|
@ -1427,6 +1448,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #FILE_SOURCE_DSC}</li>
|
* <li>Default = {@link #FILE_SOURCE_DSC}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #FILE_SOURCE_OTHER
|
* @see #FILE_SOURCE_OTHER
|
||||||
* @see #FILE_SOURCE_TRANSPARENT_SCANNER
|
* @see #FILE_SOURCE_TRANSPARENT_SCANNER
|
||||||
* @see #FILE_SOURCE_REFLEX_SCANNER
|
* @see #FILE_SOURCE_REFLEX_SCANNER
|
||||||
|
@ -1444,6 +1466,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 1</li>
|
* <li>Default = 1</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED
|
* @see #SCENE_TYPE_DIRECTLY_PHOTOGRAPHED
|
||||||
*/
|
*/
|
||||||
public static final String TAG_SCENE_TYPE = "SceneType";
|
public static final String TAG_SCENE_TYPE = "SceneType";
|
||||||
|
@ -1457,6 +1480,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #TAG_SENSING_METHOD
|
* @see #TAG_SENSING_METHOD
|
||||||
* @see #SENSOR_TYPE_ONE_CHIP
|
* @see #SENSOR_TYPE_ONE_CHIP
|
||||||
*/
|
*/
|
||||||
|
@ -1473,6 +1497,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li>
|
* <li>Default = {@link #RENDERED_PROCESS_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #RENDERED_PROCESS_NORMAL
|
* @see #RENDERED_PROCESS_NORMAL
|
||||||
* @see #RENDERED_PROCESS_CUSTOM
|
* @see #RENDERED_PROCESS_CUSTOM
|
||||||
*/
|
*/
|
||||||
|
@ -1489,6 +1514,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #EXPOSURE_MODE_AUTO
|
* @see #EXPOSURE_MODE_AUTO
|
||||||
* @see #EXPOSURE_MODE_MANUAL
|
* @see #EXPOSURE_MODE_MANUAL
|
||||||
* @see #EXPOSURE_MODE_AUTO_BRACKET
|
* @see #EXPOSURE_MODE_AUTO_BRACKET
|
||||||
|
@ -1504,6 +1530,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #WHITEBALANCE_AUTO
|
* @see #WHITEBALANCE_AUTO
|
||||||
* @see #WHITEBALANCE_MANUAL
|
* @see #WHITEBALANCE_MANUAL
|
||||||
*/
|
*/
|
||||||
|
@ -1553,6 +1580,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 0</li>
|
* <li>Default = 0</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SCENE_CAPTURE_TYPE_STANDARD
|
* @see #SCENE_CAPTURE_TYPE_STANDARD
|
||||||
* @see #SCENE_CAPTURE_TYPE_LANDSCAPE
|
* @see #SCENE_CAPTURE_TYPE_LANDSCAPE
|
||||||
* @see #SCENE_CAPTURE_TYPE_PORTRAIT
|
* @see #SCENE_CAPTURE_TYPE_PORTRAIT
|
||||||
|
@ -1569,6 +1597,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GAIN_CONTROL_NONE
|
* @see #GAIN_CONTROL_NONE
|
||||||
* @see #GAIN_CONTROL_LOW_GAIN_UP
|
* @see #GAIN_CONTROL_LOW_GAIN_UP
|
||||||
* @see #GAIN_CONTROL_HIGH_GAIN_UP
|
* @see #GAIN_CONTROL_HIGH_GAIN_UP
|
||||||
|
@ -1587,6 +1616,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #CONTRAST_NORMAL}</li>
|
* <li>Default = {@link #CONTRAST_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #CONTRAST_NORMAL
|
* @see #CONTRAST_NORMAL
|
||||||
* @see #CONTRAST_SOFT
|
* @see #CONTRAST_SOFT
|
||||||
* @see #CONTRAST_HARD
|
* @see #CONTRAST_HARD
|
||||||
|
@ -1603,6 +1633,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #SATURATION_NORMAL}</li>
|
* <li>Default = {@link #SATURATION_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SATURATION_NORMAL
|
* @see #SATURATION_NORMAL
|
||||||
* @see #SATURATION_LOW
|
* @see #SATURATION_LOW
|
||||||
* @see #SATURATION_HIGH
|
* @see #SATURATION_HIGH
|
||||||
|
@ -1619,6 +1650,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #SHARPNESS_NORMAL}</li>
|
* <li>Default = {@link #SHARPNESS_NORMAL}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SHARPNESS_NORMAL
|
* @see #SHARPNESS_NORMAL
|
||||||
* @see #SHARPNESS_SOFT
|
* @see #SHARPNESS_SOFT
|
||||||
* @see #SHARPNESS_HARD
|
* @see #SHARPNESS_HARD
|
||||||
|
@ -1646,6 +1678,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #SUBJECT_DISTANCE_RANGE_UNKNOWN
|
* @see #SUBJECT_DISTANCE_RANGE_UNKNOWN
|
||||||
* @see #SUBJECT_DISTANCE_RANGE_MACRO
|
* @see #SUBJECT_DISTANCE_RANGE_MACRO
|
||||||
* @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW
|
* @see #SUBJECT_DISTANCE_RANGE_CLOSE_VIEW
|
||||||
|
@ -1675,6 +1708,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead.
|
* @deprecated Use {@link #TAG_CAMERA_OWNER_NAME} instead.
|
||||||
*/
|
*/
|
||||||
@Deprecated
|
@Deprecated
|
||||||
|
@ -1780,6 +1814,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LATITUDE_NORTH
|
* @see #LATITUDE_NORTH
|
||||||
* @see #LATITUDE_SOUTH
|
* @see #LATITUDE_SOUTH
|
||||||
*/
|
*/
|
||||||
|
@ -1809,6 +1844,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LONGITUDE_EAST
|
* @see #LONGITUDE_EAST
|
||||||
* @see #LONGITUDE_WEST
|
* @see #LONGITUDE_WEST
|
||||||
*/
|
*/
|
||||||
|
@ -1841,6 +1877,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = 0</li>
|
* <li>Default = 0</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #ALTITUDE_ABOVE_SEA_LEVEL
|
* @see #ALTITUDE_ABOVE_SEA_LEVEL
|
||||||
* @see #ALTITUDE_BELOW_SEA_LEVEL
|
* @see #ALTITUDE_BELOW_SEA_LEVEL
|
||||||
*/
|
*/
|
||||||
|
@ -1899,6 +1936,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_MEASUREMENT_IN_PROGRESS
|
* @see #GPS_MEASUREMENT_IN_PROGRESS
|
||||||
* @see #GPS_MEASUREMENT_INTERRUPTED
|
* @see #GPS_MEASUREMENT_INTERRUPTED
|
||||||
*/
|
*/
|
||||||
|
@ -1915,6 +1953,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_MEASUREMENT_2D
|
* @see #GPS_MEASUREMENT_2D
|
||||||
* @see #GPS_MEASUREMENT_3D
|
* @see #GPS_MEASUREMENT_3D
|
||||||
*/
|
*/
|
||||||
|
@ -1941,6 +1980,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li>
|
* <li>Default = {@link #GPS_SPEED_KILOMETERS_PER_HOUR}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_SPEED_KILOMETERS_PER_HOUR
|
* @see #GPS_SPEED_KILOMETERS_PER_HOUR
|
||||||
* @see #GPS_SPEED_MILES_PER_HOUR
|
* @see #GPS_SPEED_MILES_PER_HOUR
|
||||||
* @see #GPS_SPEED_KNOTS
|
* @see #GPS_SPEED_KNOTS
|
||||||
|
@ -1968,6 +2008,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DIRECTION_TRUE
|
* @see #GPS_DIRECTION_TRUE
|
||||||
* @see #GPS_DIRECTION_MAGNETIC
|
* @see #GPS_DIRECTION_MAGNETIC
|
||||||
*/
|
*/
|
||||||
|
@ -1994,6 +2035,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DIRECTION_TRUE
|
* @see #GPS_DIRECTION_TRUE
|
||||||
* @see #GPS_DIRECTION_MAGNETIC
|
* @see #GPS_DIRECTION_MAGNETIC
|
||||||
*/
|
*/
|
||||||
|
@ -2032,6 +2074,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LATITUDE_NORTH
|
* @see #LATITUDE_NORTH
|
||||||
* @see #LATITUDE_SOUTH
|
* @see #LATITUDE_SOUTH
|
||||||
*/
|
*/
|
||||||
|
@ -2061,6 +2104,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #LONGITUDE_EAST
|
* @see #LONGITUDE_EAST
|
||||||
* @see #LONGITUDE_WEST
|
* @see #LONGITUDE_WEST
|
||||||
*/
|
*/
|
||||||
|
@ -2090,6 +2134,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
* <li>Default = {@link #GPS_DIRECTION_TRUE}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DIRECTION_TRUE
|
* @see #GPS_DIRECTION_TRUE
|
||||||
* @see #GPS_DIRECTION_MAGNETIC
|
* @see #GPS_DIRECTION_MAGNETIC
|
||||||
*/
|
*/
|
||||||
|
@ -2116,6 +2161,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li>
|
* <li>Default = {@link #GPS_DISTANCE_KILOMETERS}</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_DISTANCE_KILOMETERS
|
* @see #GPS_DISTANCE_KILOMETERS
|
||||||
* @see #GPS_DISTANCE_MILES
|
* @see #GPS_DISTANCE_MILES
|
||||||
* @see #GPS_DISTANCE_NAUTICAL_MILES
|
* @see #GPS_DISTANCE_NAUTICAL_MILES
|
||||||
|
@ -2177,6 +2223,7 @@ public class ExifInterfaceFork {
|
||||||
* <li>Default = None</li>
|
* <li>Default = None</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
|
*
|
||||||
* @see #GPS_MEASUREMENT_NO_DIFFERENTIAL
|
* @see #GPS_MEASUREMENT_NO_DIFFERENTIAL
|
||||||
* @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED
|
* @see #GPS_MEASUREMENT_DIFFERENTIAL_CORRECTED
|
||||||
*/
|
*/
|
||||||
|
@ -3132,11 +3179,18 @@ public class ExifInterfaceFork {
|
||||||
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
|
// See "Extensions to the PNG 1.2 Specification, Version 1.5.0",
|
||||||
// 3.7. eXIf Exchangeable Image File (Exif) Profile
|
// 3.7. eXIf Exchangeable Image File (Exif) Profile
|
||||||
private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
|
private static final int PNG_CHUNK_TYPE_EXIF = 'e' << 24 | 'X' << 16 | 'I' << 8 | 'f';
|
||||||
|
// See "XMP Specification Part 3: Storage in Files" section 1.1.5
|
||||||
|
private static final int PNG_CHUNK_TYPE_ITXT = 'i' << 24 | 'T' << 16 | 'X' << 8 | 't';
|
||||||
private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
|
private static final int PNG_CHUNK_TYPE_IHDR = 'I' << 24 | 'H' << 16 | 'D' << 8 | 'R';
|
||||||
private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
|
private static final int PNG_CHUNK_TYPE_IEND = 'I' << 24 | 'E' << 16 | 'N' << 8 | 'D';
|
||||||
private static final int PNG_CHUNK_TYPE_BYTE_LENGTH = 4;
|
|
||||||
private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
|
private static final int PNG_CHUNK_CRC_BYTE_LENGTH = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The keyword and 5 null bytes defined by XMP spec part 3 table 9 (section 1.1.5).
|
||||||
|
*/
|
||||||
|
@VisibleForTesting
|
||||||
|
static final byte[] PNG_ITXT_XMP_KEYWORD = "XML:com.adobe.xmp\0\0\0\0\0".getBytes(UTF_8);
|
||||||
|
|
||||||
// See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
|
// See https://developers.google.com/speed/webp/docs/riff_container, Section "WebP File Header"
|
||||||
private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'};
|
private static final byte[] WEBP_SIGNATURE_1 = new byte[]{'R', 'I', 'F', 'F'};
|
||||||
private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'};
|
private static final byte[] WEBP_SIGNATURE_2 = new byte[]{'W', 'E', 'B', 'P'};
|
||||||
|
@ -4069,20 +4123,33 @@ public class ExifInterfaceFork {
|
||||||
// Used to indicate offset from the start of the original input stream to EXIF data
|
// Used to indicate offset from the start of the original input stream to EXIF data
|
||||||
private int mOffsetToExifData;
|
private int mOffsetToExifData;
|
||||||
private int mOrfMakerNoteOffset;
|
private int mOrfMakerNoteOffset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The position of the thumbnail within the Exif data (from {@link #mOffsetToExifData}).
|
||||||
|
*/
|
||||||
private int mOrfThumbnailOffset;
|
private int mOrfThumbnailOffset;
|
||||||
|
|
||||||
private int mOrfThumbnailLength;
|
private int mOrfThumbnailLength;
|
||||||
private boolean mModified;
|
private boolean mModified;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
|
* XMP data can occur as either part of the TIFF/Exif data (tag number 700), or as a separate
|
||||||
* section of the file (e.g. a separate APP1 segment in JPEG). XMP read from within the
|
* section of the file (e.g. a separate APP1 segment in JPEG, or an iTXt chunk in PNG). XMP read
|
||||||
* TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a separate section is
|
* from within the TIFF/Exif data is stored in {@link #mAttributes}, while XMP read from a
|
||||||
* here. If both are present, the disambiguation rules vary per file format, see
|
* separate section is here. If both are present, the disambiguation rules vary per file format,
|
||||||
* {@link #getXmpHandlingForImageType(int)}.
|
* see {@link #getXmpHandlingForImageType(int)}.
|
||||||
*/
|
*/
|
||||||
@Nullable
|
@Nullable
|
||||||
private ExifAttribute mXmpFromSeparateMarker;
|
private ExifAttribute mXmpFromSeparateMarker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the file on disk contains XMP in a separate section.
|
||||||
|
*
|
||||||
|
* <p>This means the file the instance was loaded with, or the file created by the last call to
|
||||||
|
* {@link #saveAttributes()}.
|
||||||
|
*/
|
||||||
|
private boolean mFileOnDiskContainsSeparateXmpMarker;
|
||||||
|
|
||||||
// Pattern to check non zero timestamp
|
// Pattern to check non zero timestamp
|
||||||
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
|
private static final Pattern NON_ZERO_TIME_PATTERN = Pattern.compile(".*[1-9].*");
|
||||||
// Pattern to check gps timestamp
|
// Pattern to check gps timestamp
|
||||||
|
@ -4300,6 +4367,7 @@ public class ExifInterfaceFork {
|
||||||
return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
|
return XMP_HANDLING_PREFER_TIFF_700_IF_PRESENT;
|
||||||
case IMAGE_TYPE_AVIF:
|
case IMAGE_TYPE_AVIF:
|
||||||
case IMAGE_TYPE_HEIC:
|
case IMAGE_TYPE_HEIC:
|
||||||
|
case IMAGE_TYPE_PNG:
|
||||||
// RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
|
// RAF stores XMP/Exif in JPEG, but we have no documented backwards-compat obligations
|
||||||
// so we can implement the spec to store XMP in a separate APP1 segment.
|
// so we can implement the spec to store XMP in a separate APP1 segment.
|
||||||
case IMAGE_TYPE_RAF:
|
case IMAGE_TYPE_RAF:
|
||||||
|
@ -4309,10 +4377,8 @@ public class ExifInterfaceFork {
|
||||||
case IMAGE_TYPE_PEF:
|
case IMAGE_TYPE_PEF:
|
||||||
case IMAGE_TYPE_RW2:
|
case IMAGE_TYPE_RW2:
|
||||||
case IMAGE_TYPE_UNKNOWN:
|
case IMAGE_TYPE_UNKNOWN:
|
||||||
// PNG and WebP support a separate XMP chunk (so should be
|
// WebP supports a separate XMP chunk (so should be XMP_HANDLING_PREFER_SEPARATE), but
|
||||||
// XMP_HANDLING_PREFER_SEPARATE), but ExifInterface doesn't currently read or write
|
// ExifInterface doesn't currently read or write it.
|
||||||
// them.
|
|
||||||
case IMAGE_TYPE_PNG:
|
|
||||||
case IMAGE_TYPE_WEBP:
|
case IMAGE_TYPE_WEBP:
|
||||||
default:
|
default:
|
||||||
return XMP_HANDLING_TIFF_700_ONLY;
|
return XMP_HANDLING_TIFF_700_ONLY;
|
||||||
|
@ -5160,14 +5226,18 @@ public class ExifInterfaceFork {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the offset and length of the requested tag inside the image file,
|
* Returns the offset and length of the requested tag inside the image file, or {@code null} if
|
||||||
* or {@code null} if the tag is not contained.
|
* the tag is not contained.
|
||||||
*
|
*
|
||||||
* @return two-element array, the offset in the first value, and length in
|
* <p>If the attribute has been modified with {@link #setAttribute(String, String)} but not yet
|
||||||
* the second, or {@code null} if no tag was found.
|
* written to disk with {@link #saveAttributes()}, the returned range will have the correct
|
||||||
* @throws IllegalStateException if {@link #saveAttributes()} has been
|
* length for the modified value, but an offset of {@code -1} to indicate its position in the
|
||||||
* called since the underlying file was initially parsed, since
|
* file isn't known.
|
||||||
* that means offsets may have changed.
|
*
|
||||||
|
* @return two-element array, the offset in the first value, and length in the second, or {@code
|
||||||
|
* null} if no tag was found.
|
||||||
|
* @throws IllegalStateException if {@link #saveAttributes()} has been called since the
|
||||||
|
* underlying file was initially parsed, since that means offsets may have changed.
|
||||||
*/
|
*/
|
||||||
public long @Nullable [] getAttributeRange(@NonNull String tag) {
|
public long @Nullable [] getAttributeRange(@NonNull String tag) {
|
||||||
if (tag == null) {
|
if (tag == null) {
|
||||||
|
@ -5841,6 +5911,7 @@ public class ExifInterfaceFork {
|
||||||
IDENTIFIER_XMP_APP1.length, bytes.length);
|
IDENTIFIER_XMP_APP1.length, bytes.length);
|
||||||
mXmpFromSeparateMarker =
|
mXmpFromSeparateMarker =
|
||||||
new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
|
new ExifAttribute(IFD_FORMAT_BYTE, value.length, offset, value);
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -6165,6 +6236,7 @@ public class ExifInterfaceFork {
|
||||||
in.readFully(xmpBytes);
|
in.readFully(xmpBytes);
|
||||||
mXmpFromSeparateMarker =
|
mXmpFromSeparateMarker =
|
||||||
new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
|
new ExifAttribute(IFD_FORMAT_BYTE, xmpBytes.length, offset, xmpBytes);
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
|
@ -6352,10 +6424,12 @@ public class ExifInterfaceFork {
|
||||||
// See PNG (Portable Network Graphics) Specification, Version 1.2,
|
// See PNG (Portable Network Graphics) Specification, Version 1.2,
|
||||||
// 3.2. Chunk layout
|
// 3.2. Chunk layout
|
||||||
try {
|
try {
|
||||||
while (true) {
|
boolean foundExif = false;
|
||||||
|
boolean foundXmpItxt = false;
|
||||||
|
while (!foundExif || !foundXmpItxt) {
|
||||||
int length = in.readInt();
|
int length = in.readInt();
|
||||||
|
|
||||||
int type = in.readInt();
|
int type = in.readInt();
|
||||||
|
int startOfNextChunk = in.position() + length + PNG_CHUNK_CRC_BYTE_LENGTH;
|
||||||
|
|
||||||
// The first chunk must be the IHDR chunk
|
// The first chunk must be the IHDR chunk
|
||||||
if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) {
|
if (in.position() - startPosition == 16 && type != PNG_CHUNK_TYPE_IHDR) {
|
||||||
|
@ -6367,7 +6441,7 @@ public class ExifInterfaceFork {
|
||||||
if (type == PNG_CHUNK_TYPE_IEND) {
|
if (type == PNG_CHUNK_TYPE_IEND) {
|
||||||
// IEND marks the end of the image.
|
// IEND marks the end of the image.
|
||||||
break;
|
break;
|
||||||
} else if (type == PNG_CHUNK_TYPE_EXIF) {
|
} else if (type == PNG_CHUNK_TYPE_EXIF && !foundExif) {
|
||||||
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
||||||
mOffsetToExifData = in.position() - startPosition;
|
mOffsetToExifData = in.position() - startPosition;
|
||||||
|
|
||||||
|
@ -6388,20 +6462,40 @@ public class ExifInterfaceFork {
|
||||||
updateCrcWithInt(crc, type);
|
updateCrcWithInt(crc, type);
|
||||||
crc.update(data);
|
crc.update(data);
|
||||||
if ((int) crc.getValue() != dataCrcValue) {
|
if ((int) crc.getValue() != dataCrcValue) {
|
||||||
throw new IOException("Encountered invalid CRC value for PNG-EXIF chunk."
|
throw new IOException(
|
||||||
+ "\n recorded CRC value: " + dataCrcValue + ", calculated CRC "
|
"Encountered invalid CRC value for PNG-EXIF chunk."
|
||||||
+ "value: " + crc.getValue());
|
+ "\n recorded CRC value: "
|
||||||
|
+ dataCrcValue
|
||||||
|
+ ", calculated CRC "
|
||||||
|
+ "value: "
|
||||||
|
+ crc.getValue());
|
||||||
}
|
}
|
||||||
readExifSegment(data, IFD_TYPE_PRIMARY);
|
readExifSegment(data, IFD_TYPE_PRIMARY);
|
||||||
validateImages();
|
validateImages();
|
||||||
|
|
||||||
setThumbnailData(new ByteOrderedDataInputStream(data));
|
setThumbnailData(new ByteOrderedDataInputStream(data));
|
||||||
break;
|
foundExif = true;
|
||||||
} else {
|
} else if (type == PNG_CHUNK_TYPE_ITXT
|
||||||
|
&& !foundXmpItxt
|
||||||
|
&& length >= PNG_ITXT_XMP_KEYWORD.length) {
|
||||||
|
// Read the 17 byte keyword and 5 expected null bytes.
|
||||||
|
byte[] keyword = new byte[PNG_ITXT_XMP_KEYWORD.length];
|
||||||
|
in.readFully(keyword);
|
||||||
|
if (Arrays.equals(keyword, PNG_ITXT_XMP_KEYWORD)) {
|
||||||
|
int xmpDataOffset = in.position() - startPosition;
|
||||||
|
int xmpLength = length - keyword.length;
|
||||||
|
byte[] xmpData = new byte[xmpLength];
|
||||||
|
in.readFully(xmpData);
|
||||||
|
mXmpFromSeparateMarker =
|
||||||
|
new ExifAttribute(
|
||||||
|
IFD_FORMAT_BYTE, xmpLength, xmpDataOffset, xmpData);
|
||||||
|
foundXmpItxt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Skip to next chunk
|
// Skip to next chunk
|
||||||
in.skipFully(length + PNG_CHUNK_CRC_BYTE_LENGTH);
|
in.skipFully(startOfNextChunk - in.position());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = foundXmpItxt;
|
||||||
} catch (EOFException e) {
|
} catch (EOFException e) {
|
||||||
// Should not reach here. Will only reach here if the file is corrupted or
|
// Should not reach here. Will only reach here if the file is corrupted or
|
||||||
// does not follow the PNG specifications
|
// does not follow the PNG specifications
|
||||||
|
@ -6464,9 +6558,8 @@ public class ExifInterfaceFork {
|
||||||
// Exif data in WebP images (e.g.
|
// Exif data in WebP images (e.g.
|
||||||
// https://github.com/ImageMagick/ImageMagick/issues/3140)
|
// https://github.com/ImageMagick/ImageMagick/issues/3140)
|
||||||
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
|
if (startsWith(payload, IDENTIFIER_EXIF_APP1)) {
|
||||||
int adjustedChunkSize = chunkSize - IDENTIFIER_EXIF_APP1.length;
|
|
||||||
payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length,
|
payload = Arrays.copyOfRange(payload, IDENTIFIER_EXIF_APP1.length,
|
||||||
adjustedChunkSize);
|
payload.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
// Save offset to EXIF data for handling thumbnail and attribute offsets.
|
||||||
|
@ -6522,7 +6615,7 @@ public class ExifInterfaceFork {
|
||||||
// Write EXIF APP1 segment
|
// Write EXIF APP1 segment
|
||||||
dataOutputStream.writeByte(MARKER);
|
dataOutputStream.writeByte(MARKER);
|
||||||
dataOutputStream.writeByte(MARKER_APP1);
|
dataOutputStream.writeByte(MARKER_APP1);
|
||||||
writeExifSegment(dataOutputStream);
|
mOffsetToExifData = writeExifSegment(dataOutputStream);
|
||||||
|
|
||||||
if (mXmpFromSeparateMarker != null) {
|
if (mXmpFromSeparateMarker != null) {
|
||||||
// Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
|
// Write XMP APP1 segment. The XMP spec (part 3, section 1.1.3) recommends for this to
|
||||||
|
@ -6533,6 +6626,7 @@ public class ExifInterfaceFork {
|
||||||
dataOutputStream.writeUnsignedShort(length);
|
dataOutputStream.writeUnsignedShort(length);
|
||||||
dataOutputStream.write(IDENTIFIER_XMP_APP1);
|
dataOutputStream.write(IDENTIFIER_XMP_APP1);
|
||||||
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
|
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] bytes = new byte[4096];
|
byte[] bytes = new byte[4096];
|
||||||
|
@ -6627,60 +6721,76 @@ public class ExifInterfaceFork {
|
||||||
// Copy PNG signature bytes
|
// Copy PNG signature bytes
|
||||||
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
|
copy(dataInputStream, dataOutputStream, PNG_SIGNATURE.length);
|
||||||
|
|
||||||
// EXIF chunk can appear anywhere between the first (IHDR) and last (IEND) chunks, except
|
boolean needToWriteExif = true;
|
||||||
// between IDAT chunks.
|
boolean needToWriteXmp = mXmpFromSeparateMarker != null;
|
||||||
// Adhering to these rules,
|
while (needToWriteExif || needToWriteXmp) {
|
||||||
// 1) if EXIF chunk did not exist in the original file, it will be stored right after the
|
int chunkLength = dataInputStream.readInt();
|
||||||
// first chunk,
|
int chunkType = dataInputStream.readInt();
|
||||||
// 2) if EXIF chunk existed in the original file, it will be stored in the same location.
|
if (chunkType == PNG_CHUNK_TYPE_IHDR) {
|
||||||
|
dataOutputStream.writeInt(chunkLength);
|
||||||
|
dataOutputStream.writeInt(chunkType);
|
||||||
|
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
if (mOffsetToExifData == 0) {
|
if (mOffsetToExifData == 0) {
|
||||||
// Copy IHDR chunk bytes
|
// There was no Exif segment in the original file, so we put it directly
|
||||||
int ihdrChunkLength = dataInputStream.readInt();
|
// after the IHDR chunk.
|
||||||
dataOutputStream.writeInt(ihdrChunkLength);
|
writePngExifChunk(dataOutputStream);
|
||||||
copy(dataInputStream, dataOutputStream, PNG_CHUNK_TYPE_BYTE_LENGTH
|
needToWriteExif = false;
|
||||||
+ ihdrChunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
|
||||||
} else {
|
|
||||||
// Copy up until the point where EXIF chunk length information is stored.
|
|
||||||
int copyLength = mOffsetToExifData - PNG_SIGNATURE.length
|
|
||||||
- 4 /* PNG EXIF chunk length bytes */
|
|
||||||
- PNG_CHUNK_TYPE_BYTE_LENGTH;
|
|
||||||
copy(dataInputStream, dataOutputStream, copyLength);
|
|
||||||
|
|
||||||
// Skip to the start of the chunk after the EXIF chunk
|
|
||||||
int exifChunkLength = dataInputStream.readInt();
|
|
||||||
dataInputStream.skipFully(PNG_CHUNK_TYPE_BYTE_LENGTH + exifChunkLength
|
|
||||||
+ PNG_CHUNK_CRC_BYTE_LENGTH);
|
|
||||||
}
|
}
|
||||||
|
if (mXmpFromSeparateMarker != null && !mFileOnDiskContainsSeparateXmpMarker) {
|
||||||
// Write EXIF data
|
writePngXmpItxtChunk(dataOutputStream);
|
||||||
ByteArrayOutputStream exifByteArrayOutputStream = null;
|
needToWriteXmp = false;
|
||||||
try {
|
}
|
||||||
// A byte array is needed to calculate the CRC value of this chunk which requires
|
continue;
|
||||||
// the chunk type bytes and the chunk data bytes.
|
} else if (chunkType == PNG_CHUNK_TYPE_EXIF && needToWriteExif) {
|
||||||
exifByteArrayOutputStream = new ByteArrayOutputStream();
|
writePngExifChunk(dataOutputStream);
|
||||||
ByteOrderedDataOutputStream exifDataOutputStream =
|
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN);
|
needToWriteExif = false;
|
||||||
|
continue;
|
||||||
// Store Exif data in separate byte array
|
} else if (chunkType == PNG_CHUNK_TYPE_ITXT && needToWriteXmp) {
|
||||||
writeExifSegment(exifDataOutputStream);
|
writePngXmpItxtChunk(dataOutputStream);
|
||||||
byte[] exifBytes =
|
dataInputStream.skipFully(chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
((ByteArrayOutputStream) exifDataOutputStream.mOutputStream).toByteArray();
|
needToWriteXmp = false;
|
||||||
|
continue;
|
||||||
// Write EXIF chunk data
|
}
|
||||||
dataOutputStream.write(exifBytes);
|
dataOutputStream.writeInt(chunkLength);
|
||||||
|
dataOutputStream.writeInt(chunkType);
|
||||||
// Write EXIF chunk CRC
|
copy(dataInputStream, dataOutputStream, chunkLength + PNG_CHUNK_CRC_BYTE_LENGTH);
|
||||||
CRC32 crc = new CRC32();
|
|
||||||
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
|
|
||||||
dataOutputStream.writeInt((int) crc.getValue());
|
|
||||||
} finally {
|
|
||||||
closeQuietly(exifByteArrayOutputStream);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the rest of the file
|
// Copy the rest of the file
|
||||||
copy(dataInputStream, dataOutputStream);
|
copy(dataInputStream, dataOutputStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void writePngExifChunk(ByteOrderedDataOutputStream dataOutputStream)
|
||||||
|
throws IOException {
|
||||||
|
// Write the eXIF chunk out to an intermediate byte array so we can calculate the CRC value.
|
||||||
|
ByteArrayOutputStream exifByteArrayOutputStream = new ByteArrayOutputStream();
|
||||||
|
// Write eXIF chunk data (including chunk type & length).
|
||||||
|
int exifOffset =
|
||||||
|
writeExifSegment(
|
||||||
|
new ByteOrderedDataOutputStream(exifByteArrayOutputStream, BIG_ENDIAN));
|
||||||
|
mOffsetToExifData = dataOutputStream.mOutputStream.size() + exifOffset;
|
||||||
|
byte[] exifBytes = exifByteArrayOutputStream.toByteArray();
|
||||||
|
dataOutputStream.write(exifBytes);
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
crc.update(exifBytes, 4 /* skip length bytes */, exifBytes.length - 4);
|
||||||
|
dataOutputStream.writeInt((int) crc.getValue());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePngXmpItxtChunk(ByteOrderedDataOutputStream dataOutputStream)
|
||||||
|
throws IOException {
|
||||||
|
dataOutputStream.writeInt(mXmpFromSeparateMarker.bytes.length + 22);
|
||||||
|
CRC32 crc = new CRC32();
|
||||||
|
dataOutputStream.writeInt(PNG_CHUNK_TYPE_ITXT);
|
||||||
|
updateCrcWithInt(crc, PNG_CHUNK_TYPE_ITXT);
|
||||||
|
dataOutputStream.write(PNG_ITXT_XMP_KEYWORD);
|
||||||
|
crc.update(PNG_ITXT_XMP_KEYWORD);
|
||||||
|
dataOutputStream.write(mXmpFromSeparateMarker.bytes);
|
||||||
|
crc.update(mXmpFromSeparateMarker.bytes);
|
||||||
|
dataOutputStream.writeInt((int) crc.getValue());
|
||||||
|
mFileOnDiskContainsSeparateXmpMarker = true;
|
||||||
|
}
|
||||||
|
|
||||||
// A WebP file has a header and a series of chunks.
|
// A WebP file has a header and a series of chunks.
|
||||||
// The header is composed of:
|
// The header is composed of:
|
||||||
// "RIFF" + File Size + "WEBP"
|
// "RIFF" + File Size + "WEBP"
|
||||||
|
@ -6726,11 +6836,12 @@ public class ExifInterfaceFork {
|
||||||
|
|
||||||
// WebP signature
|
// WebP signature
|
||||||
copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
|
copy(totalInputStream, totalOutputStream, WEBP_SIGNATURE_1.length);
|
||||||
// File length will be written after all the chunks have been written
|
int riffLength = totalInputStream.readInt();
|
||||||
totalInputStream.skipFully(WEBP_FILE_SIZE_BYTE_LENGTH + WEBP_SIGNATURE_2.length);
|
totalInputStream.skipFully(WEBP_SIGNATURE_2.length);
|
||||||
|
|
||||||
// Create a separate byte array to calculate file length
|
// Create a separate byte array to calculate file length
|
||||||
ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
|
ByteArrayOutputStream nonHeaderByteArrayOutputStream = null;
|
||||||
|
int exifOffset = -1;
|
||||||
try {
|
try {
|
||||||
nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
|
nonHeaderByteArrayOutputStream = new ByteArrayOutputStream();
|
||||||
ByteOrderedDataOutputStream nonHeaderOutputStream =
|
ByteOrderedDataOutputStream nonHeaderOutputStream =
|
||||||
|
@ -6756,7 +6867,7 @@ public class ExifInterfaceFork {
|
||||||
totalInputStream.skipFully(exifChunkLength);
|
totalInputStream.skipFully(exifChunkLength);
|
||||||
|
|
||||||
// Write new EXIF chunk to output stream
|
// Write new EXIF chunk to output stream
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
} else {
|
} else {
|
||||||
// EXIF chunk does not exist in the original file
|
// EXIF chunk does not exist in the original file
|
||||||
byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
|
byte[] firstChunkType = new byte[WEBP_CHUNK_TYPE_BYTE_LENGTH];
|
||||||
|
@ -6801,7 +6912,7 @@ public class ExifInterfaceFork {
|
||||||
animationFinished = true;
|
animationFinished = true;
|
||||||
}
|
}
|
||||||
if (animationFinished) {
|
if (animationFinished) {
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
|
copyWebPChunk(totalInputStream, nonHeaderOutputStream, type);
|
||||||
|
@ -6810,7 +6921,7 @@ public class ExifInterfaceFork {
|
||||||
// Skip until we find the VP8 or VP8L chunk
|
// Skip until we find the VP8 or VP8L chunk
|
||||||
copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
|
copyChunksUpToGivenChunkType(totalInputStream, nonHeaderOutputStream,
|
||||||
WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
|
WEBP_CHUNK_TYPE_VP8, WEBP_CHUNK_TYPE_VP8L);
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
}
|
}
|
||||||
} else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
|
} else if (Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8)
|
||||||
|| Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
|
|| Arrays.equals(firstChunkType, WEBP_CHUNK_TYPE_VP8L)) {
|
||||||
|
@ -6897,18 +7008,24 @@ public class ExifInterfaceFork {
|
||||||
copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
|
copy(totalInputStream, nonHeaderOutputStream, bytesToRead);
|
||||||
|
|
||||||
// Write EXIF chunk
|
// Write EXIF chunk
|
||||||
writeExifSegment(nonHeaderOutputStream);
|
exifOffset = writeExifSegment(nonHeaderOutputStream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the rest of the file
|
// Copy the rest of the RIFF part of the file
|
||||||
copy(totalInputStream, nonHeaderOutputStream);
|
int remainingRiffBytes = riffLength + 8 - totalInputStream.position();
|
||||||
|
copy(totalInputStream, nonHeaderOutputStream, remainingRiffBytes);
|
||||||
|
|
||||||
// Write file length + second signature
|
// Write file length + second signature
|
||||||
totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
|
totalOutputStream.writeInt(nonHeaderByteArrayOutputStream.size()
|
||||||
+ WEBP_SIGNATURE_2.length);
|
+ WEBP_SIGNATURE_2.length);
|
||||||
totalOutputStream.write(WEBP_SIGNATURE_2);
|
totalOutputStream.write(WEBP_SIGNATURE_2);
|
||||||
|
if (exifOffset != -1) {
|
||||||
|
mOffsetToExifData = totalOutputStream.mOutputStream.size() + exifOffset;
|
||||||
|
}
|
||||||
nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
|
nonHeaderByteArrayOutputStream.writeTo(totalOutputStream);
|
||||||
|
// Copy any non-RIFF trailing data
|
||||||
|
copy(totalInputStream, totalOutputStream);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IOException("Failed to save WebP file", e);
|
throw new IOException("Failed to save WebP file", e);
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -7624,7 +7741,12 @@ public class ExifInterfaceFork {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Writes an Exif segment into the given output stream.
|
/**
|
||||||
|
* Writes an Exif segment into the given output stream.
|
||||||
|
*
|
||||||
|
* @return The offset of the start of the Exif data (the byte-order marker) written into {@code
|
||||||
|
* dataOutputStream}.
|
||||||
|
*/
|
||||||
private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException {
|
private int writeExifSegment(ByteOrderedDataOutputStream dataOutputStream) throws IOException {
|
||||||
// The following variables are for calculating each IFD tag group size in bytes.
|
// The following variables are for calculating each IFD tag group size in bytes.
|
||||||
int[] ifdOffsets = new int[EXIF_TAGS.length];
|
int[] ifdOffsets = new int[EXIF_TAGS.length];
|
||||||
|
@ -7772,6 +7894,8 @@ public class ExifInterfaceFork {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int offsetToExifData = dataOutputStream.mOutputStream.size();
|
||||||
|
|
||||||
// Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
|
// Write TIFF Headers. See JEITA CP-3451C Section 4.5.2. Table 1.
|
||||||
dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
|
dataOutputStream.writeShort(mExifByteOrder == BIG_ENDIAN ? BYTE_ALIGN_MM : BYTE_ALIGN_II);
|
||||||
dataOutputStream.setByteOrder(mExifByteOrder);
|
dataOutputStream.setByteOrder(mExifByteOrder);
|
||||||
|
@ -7844,7 +7968,7 @@ public class ExifInterfaceFork {
|
||||||
// Reset the byte order to big endian in order to write remaining parts of the JPEG file.
|
// Reset the byte order to big endian in order to write remaining parts of the JPEG file.
|
||||||
dataOutputStream.setByteOrder(BIG_ENDIAN);
|
dataOutputStream.setByteOrder(BIG_ENDIAN);
|
||||||
|
|
||||||
return totalSize;
|
return offsetToExifData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -8240,12 +8364,12 @@ public class ExifInterfaceFork {
|
||||||
// An output stream to write EXIF data area, which can be written in either little or big endian
|
// An output stream to write EXIF data area, which can be written in either little or big endian
|
||||||
// order.
|
// order.
|
||||||
private static class ByteOrderedDataOutputStream extends FilterOutputStream {
|
private static class ByteOrderedDataOutputStream extends FilterOutputStream {
|
||||||
final OutputStream mOutputStream;
|
final DataOutputStream mOutputStream;
|
||||||
private ByteOrder mByteOrder;
|
private ByteOrder mByteOrder;
|
||||||
|
|
||||||
public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
|
public ByteOrderedDataOutputStream(OutputStream out, ByteOrder byteOrder) {
|
||||||
super(out);
|
super(out);
|
||||||
mOutputStream = out;
|
mOutputStream = new DataOutputStream(out);
|
||||||
mByteOrder = byteOrder;
|
mByteOrder = byteOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ android.enableJetifier=true
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
android.defaults.buildfeatures.buildconfig=true
|
|
||||||
android.nonTransitiveRClass=false
|
android.nonTransitiveRClass=false
|
||||||
android.nonFinalResIds=false
|
android.nonFinalResIds=false
|
||||||
|
|
||||||
|
|
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
|
||||||
|
|
|
@ -8,9 +8,9 @@ pluginManagement {
|
||||||
}
|
}
|
||||||
settings.ext.flutterSdkPath = flutterSdkPath()
|
settings.ext.flutterSdkPath = flutterSdkPath()
|
||||||
|
|
||||||
settings.ext.kotlin_version = '1.9.24'
|
settings.ext.kotlin_version = '2.1.10'
|
||||||
settings.ext.ksp_version = "$kotlin_version-1.0.20"
|
settings.ext.ksp_version = "$kotlin_version-1.0.29"
|
||||||
settings.ext.agp_version = '8.7.0'
|
settings.ext.agp_version = '8.8.0'
|
||||||
|
|
||||||
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
|
3
fastlane/metadata/android/en-US/changelogs/143.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/143.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
In v1.12.3:
|
||||||
|
- edit locations via GPX tracks
|
||||||
|
Full changelog available on GitHub
|
3
fastlane/metadata/android/en-US/changelogs/14301.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/14301.txt
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
In v1.12.3:
|
||||||
|
- edit locations via GPX tracks
|
||||||
|
Full changelog available on GitHub
|
|
@ -2,4 +2,4 @@
|
||||||
|
|
||||||
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
<b>Navigation and search</b> is an important part of <i>Aves</i>. The goal is for users to easily flow from albums to photos to tags to maps, etc.
|
||||||
|
|
||||||
<i>Aves</i> integrates with Android (including Android TV) with features such as <b>widgets</b>, <b>app shortcuts</b>, <b>screen saver</b> and <b>global search</b> handling. It also works as a <b>media viewer and picker</b>.
|
<i>Aves</i> integroituu Androidiin (mukaan lukien Android TV) ja sisältää ominaisuuksia, kuten <b>vempaimet</b>, <b>sovellusten pikakuvakkeet</b>, <b>näytönsäästäjä</b> ja <b>laajamittaisen haun</b> käsittely. Se toimii myös <b>median katselu- ja poimijana</b>.
|
||||||
|
|
|
@ -7,6 +7,7 @@ enum AppMode {
|
||||||
pickFilteredMediaInternal,
|
pickFilteredMediaInternal,
|
||||||
pickUnfilteredMediaInternal,
|
pickUnfilteredMediaInternal,
|
||||||
pickFilterInternal,
|
pickFilterInternal,
|
||||||
|
previewMap,
|
||||||
screenSaver,
|
screenSaver,
|
||||||
setWallpaper,
|
setWallpaper,
|
||||||
slideshow,
|
slideshow,
|
||||||
|
|
|
@ -1564,5 +1564,13 @@
|
||||||
"newDynamicAlbumDialogTitle": "ألبوم ديناميكي جديد",
|
"newDynamicAlbumDialogTitle": "ألبوم ديناميكي جديد",
|
||||||
"@newDynamicAlbumDialogTitle": {},
|
"@newDynamicAlbumDialogTitle": {},
|
||||||
"chipActionDecompose": "فصل",
|
"chipActionDecompose": "فصل",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "استيراد GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "التحول الزمني",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"removeEntryMetadataDialogAll": "الكل",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1602,5 +1602,13 @@
|
||||||
"tagEditorDiscardDialogMessage": "Искате ли да отхвърлите промените?",
|
"tagEditorDiscardDialogMessage": "Искате ли да отхвърлите промените?",
|
||||||
"@tagEditorDiscardDialogMessage": {},
|
"@tagEditorDiscardDialogMessage": {},
|
||||||
"filePickerUseThisFolder": "Използвай тази папка",
|
"filePickerUseThisFolder": "Използвай тази папка",
|
||||||
"@filePickerUseThisFolder": {}
|
"@filePickerUseThisFolder": {},
|
||||||
|
"chipActionDecompose": "Раздели",
|
||||||
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "Градуси, десетични минути",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Импорт GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Изместване на времето",
|
||||||
|
"@editEntryLocationDialogTimeShift": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -508,14 +508,17 @@
|
||||||
"editEntryLocationDialogTitle": "Location",
|
"editEntryLocationDialogTitle": "Location",
|
||||||
"editEntryLocationDialogSetCustom": "Set custom location",
|
"editEntryLocationDialogSetCustom": "Set custom location",
|
||||||
"editEntryLocationDialogChooseOnMap": "Choose on map",
|
"editEntryLocationDialogChooseOnMap": "Choose on map",
|
||||||
|
"editEntryLocationDialogImportGpx": "Import GPX",
|
||||||
"editEntryLocationDialogLatitude": "Latitude",
|
"editEntryLocationDialogLatitude": "Latitude",
|
||||||
"editEntryLocationDialogLongitude": "Longitude",
|
"editEntryLocationDialogLongitude": "Longitude",
|
||||||
|
"editEntryLocationDialogTimeShift": "Time shift",
|
||||||
|
|
||||||
"locationPickerUseThisLocationButton": "Use this location",
|
"locationPickerUseThisLocationButton": "Use this location",
|
||||||
|
|
||||||
"editEntryRatingDialogTitle": "Rating",
|
"editEntryRatingDialogTitle": "Rating",
|
||||||
|
|
||||||
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
"removeEntryMetadataDialogTitle": "Metadata Removal",
|
||||||
|
"removeEntryMetadataDialogAll": "All",
|
||||||
"removeEntryMetadataDialogMore": "More",
|
"removeEntryMetadataDialogMore": "More",
|
||||||
|
|
||||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
|
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
|
||||||
|
|
|
@ -111,7 +111,7 @@
|
||||||
"@chipActionUnpin": {},
|
"@chipActionUnpin": {},
|
||||||
"chipActionRename": "Muuda nime",
|
"chipActionRename": "Muuda nime",
|
||||||
"@chipActionRename": {},
|
"@chipActionRename": {},
|
||||||
"chipActionShowCountryStates": "Näita riike",
|
"chipActionShowCountryStates": "Näita osariike",
|
||||||
"@chipActionShowCountryStates": {},
|
"@chipActionShowCountryStates": {},
|
||||||
"chipActionCreateAlbum": "Loo album",
|
"chipActionCreateAlbum": "Loo album",
|
||||||
"@chipActionCreateAlbum": {},
|
"@chipActionCreateAlbum": {},
|
||||||
|
@ -326,7 +326,7 @@
|
||||||
"@filterTypeMotionPhotoLabel": {},
|
"@filterTypeMotionPhotoLabel": {},
|
||||||
"filterTypePanoramaLabel": "Panoraam",
|
"filterTypePanoramaLabel": "Panoraam",
|
||||||
"@filterTypePanoramaLabel": {},
|
"@filterTypePanoramaLabel": {},
|
||||||
"filterTypeRawLabel": "Töötlemata raw-vormingus foto",
|
"filterTypeRawLabel": "Raw-vorming",
|
||||||
"@filterTypeRawLabel": {},
|
"@filterTypeRawLabel": {},
|
||||||
"filterTypeSphericalVideoLabel": "360° video",
|
"filterTypeSphericalVideoLabel": "360° video",
|
||||||
"@filterTypeSphericalVideoLabel": {},
|
"@filterTypeSphericalVideoLabel": {},
|
||||||
|
@ -963,7 +963,7 @@
|
||||||
"@drawerCollectionMotionPhotos": {},
|
"@drawerCollectionMotionPhotos": {},
|
||||||
"drawerCollectionPanoramas": "Panoraamfotod",
|
"drawerCollectionPanoramas": "Panoraamfotod",
|
||||||
"@drawerCollectionPanoramas": {},
|
"@drawerCollectionPanoramas": {},
|
||||||
"drawerCollectionRaws": "Töötlemata fotod",
|
"drawerCollectionRaws": "Raw-vormingus fotod",
|
||||||
"@drawerCollectionRaws": {},
|
"@drawerCollectionRaws": {},
|
||||||
"drawerCollectionSphericalVideos": "360° videod",
|
"drawerCollectionSphericalVideos": "360° videod",
|
||||||
"@drawerCollectionSphericalVideos": {},
|
"@drawerCollectionSphericalVideos": {},
|
||||||
|
@ -1211,7 +1211,7 @@
|
||||||
"@settingsThumbnailShowMotionPhotoIcon": {},
|
"@settingsThumbnailShowMotionPhotoIcon": {},
|
||||||
"settingsThumbnailShowRating": "Näita hinnangute ikooni",
|
"settingsThumbnailShowRating": "Näita hinnangute ikooni",
|
||||||
"@settingsThumbnailShowRating": {},
|
"@settingsThumbnailShowRating": {},
|
||||||
"settingsThumbnailShowRawIcon": "Näita töötlemata fotode ikooni",
|
"settingsThumbnailShowRawIcon": "Näita Raw-vormingu ikooni",
|
||||||
"@settingsThumbnailShowRawIcon": {},
|
"@settingsThumbnailShowRawIcon": {},
|
||||||
"settingsThumbnailShowVideoDuration": "Näita videote kestust",
|
"settingsThumbnailShowVideoDuration": "Näita videote kestust",
|
||||||
"@settingsThumbnailShowVideoDuration": {},
|
"@settingsThumbnailShowVideoDuration": {},
|
||||||
|
@ -1604,5 +1604,13 @@
|
||||||
"viewerInfoViewXmlLinkText": "Vaata XMLi",
|
"viewerInfoViewXmlLinkText": "Vaata XMLi",
|
||||||
"@viewerInfoViewXmlLinkText": {},
|
"@viewerInfoViewXmlLinkText": {},
|
||||||
"chipActionDecompose": "Poolita",
|
"chipActionDecompose": "Poolita",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "KKM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Ajanihe",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Impordi GPX-fail",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Kõik",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -424,5 +424,7 @@
|
||||||
"storageVolumeDescriptionFallbackPrimary": "Sisäinen tallennustila",
|
"storageVolumeDescriptionFallbackPrimary": "Sisäinen tallennustila",
|
||||||
"@storageVolumeDescriptionFallbackPrimary": {},
|
"@storageVolumeDescriptionFallbackPrimary": {},
|
||||||
"storageVolumeDescriptionFallbackNonPrimary": "SD-kortti",
|
"storageVolumeDescriptionFallbackNonPrimary": "SD-kortti",
|
||||||
"@storageVolumeDescriptionFallbackNonPrimary": {}
|
"@storageVolumeDescriptionFallbackNonPrimary": {},
|
||||||
|
"aboutCreditsSectionTitle": "Kiitettävää",
|
||||||
|
"@aboutCreditsSectionTitle": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1406,5 +1406,13 @@
|
||||||
"collectionActionAddDynamicAlbum": "Ajouter un album dynamique",
|
"collectionActionAddDynamicAlbum": "Ajouter un album dynamique",
|
||||||
"@collectionActionAddDynamicAlbum": {},
|
"@collectionActionAddDynamicAlbum": {},
|
||||||
"chipActionDecompose": "Scinder",
|
"chipActionDecompose": "Scinder",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Décalage temporel",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Importer un fichier GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Tout",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1562,5 +1562,15 @@
|
||||||
"newDynamicAlbumDialogTitle": "Új Dinamikus Album",
|
"newDynamicAlbumDialogTitle": "Új Dinamikus Album",
|
||||||
"@newDynamicAlbumDialogTitle": {},
|
"@newDynamicAlbumDialogTitle": {},
|
||||||
"appExportDynamicAlbums": "Dinamikus albumok",
|
"appExportDynamicAlbums": "Dinamikus albumok",
|
||||||
"@appExportDynamicAlbums": {}
|
"@appExportDynamicAlbums": {},
|
||||||
|
"chipActionDecompose": "Felosztás",
|
||||||
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "GPX importálás",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Időeltolódás",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Összes",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1406,5 +1406,11 @@
|
||||||
"newDynamicAlbumDialogTitle": "Album Dinamis Baru",
|
"newDynamicAlbumDialogTitle": "Album Dinamis Baru",
|
||||||
"@newDynamicAlbumDialogTitle": {},
|
"@newDynamicAlbumDialogTitle": {},
|
||||||
"chipActionDecompose": "Pisah",
|
"chipActionDecompose": "Pisah",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Impor GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Pergeseran waktu",
|
||||||
|
"@editEntryLocationDialogTimeShift": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1420,7 +1420,7 @@
|
||||||
"@authenticateToUnlockVault": {},
|
"@authenticateToUnlockVault": {},
|
||||||
"chipActionConfigureVault": "Stilla öryggisgeymslu",
|
"chipActionConfigureVault": "Stilla öryggisgeymslu",
|
||||||
"@chipActionConfigureVault": {},
|
"@chipActionConfigureVault": {},
|
||||||
"newVaultWarningDialogMessage": "Atriði í öryggisgeymslum eru einungis aðgengileg í þessu forriti og eingum öðrum.\n\nEf þú fjarlægir þetta forrit, eða hreinsar gögn forritsins, muntu tapa öllum þessum atriðum.",
|
"newVaultWarningDialogMessage": "Atriði í öryggisgeymslum eru einungis aðgengileg í þessu forriti og engum öðrum.\n\nEf þú fjarlægir þetta forrit, eða hreinsar gögn forritsins, muntu tapa öllum þessum atriðum.",
|
||||||
"@newVaultWarningDialogMessage": {},
|
"@newVaultWarningDialogMessage": {},
|
||||||
"keepScreenOnViewerOnly": "Aðeins síða skoðara",
|
"keepScreenOnViewerOnly": "Aðeins síða skoðara",
|
||||||
"@keepScreenOnViewerOnly": {},
|
"@keepScreenOnViewerOnly": {},
|
||||||
|
@ -1520,5 +1520,55 @@
|
||||||
"chipActionShowCollection": "Sýna í safni",
|
"chipActionShowCollection": "Sýna í safni",
|
||||||
"@chipActionShowCollection": {},
|
"@chipActionShowCollection": {},
|
||||||
"mapAttributionOsmData": "Kortagögn frá © [OpenStreetMap](https://www.openstreetmap.org/copyright) þátttakendum",
|
"mapAttributionOsmData": "Kortagögn frá © [OpenStreetMap](https://www.openstreetmap.org/copyright) þátttakendum",
|
||||||
"@mapAttributionOsmData": {}
|
"@mapAttributionOsmData": {},
|
||||||
|
"explorerActionSelectStorageVolume": "Veldu geymslu",
|
||||||
|
"@explorerActionSelectStorageVolume": {},
|
||||||
|
"chipActionDecompose": "Skipta upp",
|
||||||
|
"@chipActionDecompose": {},
|
||||||
|
"newDynamicAlbumDialogTitle": "Nýtt breytilegt albúm",
|
||||||
|
"@newDynamicAlbumDialogTitle": {},
|
||||||
|
"collectionActionAddDynamicAlbum": "Bæta við breytilegu albúmi",
|
||||||
|
"@collectionActionAddDynamicAlbum": {},
|
||||||
|
"sortOrderShortestFirst": "Stysta fyrst",
|
||||||
|
"@sortOrderShortestFirst": {},
|
||||||
|
"mapStyleOpenTopoMap": "OpenTopoMap",
|
||||||
|
"@mapStyleOpenTopoMap": {},
|
||||||
|
"chipActionRemove": "Fjarlægja",
|
||||||
|
"@chipActionRemove": {},
|
||||||
|
"albumTierDynamic": "Breytilegt",
|
||||||
|
"@albumTierDynamic": {},
|
||||||
|
"newAlbumDialogAlbumAlreadyExistsHelper": "Albúm er þegar til staðar",
|
||||||
|
"@newAlbumDialogAlbumAlreadyExistsHelper": {},
|
||||||
|
"dynamicAlbumAlreadyExists": "Breytilegt albúm er þegar til staðar",
|
||||||
|
"@dynamicAlbumAlreadyExists": {},
|
||||||
|
"selectStorageVolumeDialogTitle": "Veldu geymslu",
|
||||||
|
"@selectStorageVolumeDialogTitle": {},
|
||||||
|
"appExportDynamicAlbums": "Breytileg albúm",
|
||||||
|
"@appExportDynamicAlbums": {},
|
||||||
|
"mapAttributionOsmLiberty": "Kortaflísar frá [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Hýst hjá [OSM Americana](https://tile.ourmap.us)",
|
||||||
|
"@mapAttributionOsmLiberty": {},
|
||||||
|
"videoActionShowNextFrame": "Sýna næsta ramma",
|
||||||
|
"@videoActionShowNextFrame": {},
|
||||||
|
"mapAttributionOpenTopoMap": "[SRTM](https://www.earthdata.nasa.gov/sensors/srtm) | Kortaflísar frá [OpenTopoMap](https://opentopomap.org/), [CC BY-SA](https://creativecommons.org/licenses/by-sa/3.0/)",
|
||||||
|
"@mapAttributionOpenTopoMap": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"videoActionShowPreviousFrame": "Sýna fyrri ramma",
|
||||||
|
"@videoActionShowPreviousFrame": {},
|
||||||
|
"setHomeCustom": "Sérsniðið",
|
||||||
|
"@setHomeCustom": {},
|
||||||
|
"sortByDuration": "Eftir tímalengd",
|
||||||
|
"@sortByDuration": {},
|
||||||
|
"mapStyleOsmLiberty": "OSM Liberty",
|
||||||
|
"@mapStyleOsmLiberty": {},
|
||||||
|
"sortOrderLongestFirst": "Lengsta fyrst",
|
||||||
|
"@sortOrderLongestFirst": {},
|
||||||
|
"chipActionGoToExplorerPage": "Sýna í skráastjóra",
|
||||||
|
"@chipActionGoToExplorerPage": {},
|
||||||
|
"explorerPageTitle": "Skráastjóri",
|
||||||
|
"@explorerPageTitle": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Flytja inn GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Tímahliðrun",
|
||||||
|
"@editEntryLocationDialogTimeShift": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1404,5 +1404,13 @@
|
||||||
"mapStyleOpenTopoMap": "OpenTopoMap",
|
"mapStyleOpenTopoMap": "OpenTopoMap",
|
||||||
"@mapStyleOpenTopoMap": {},
|
"@mapStyleOpenTopoMap": {},
|
||||||
"mapAttributionOsmLiberty": "Tasselli di [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Ospitato da [OSM Americana](https://tile.ourmap.us)",
|
"mapAttributionOsmLiberty": "Tasselli di [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Ospitato da [OSM Americana](https://tile.ourmap.us)",
|
||||||
"@mapAttributionOsmLiberty": {}
|
"@mapAttributionOsmLiberty": {},
|
||||||
|
"chipActionDecompose": "Dividi",
|
||||||
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Scostamento tempo",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Importa GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -205,9 +205,9 @@
|
||||||
"@filterMimeImageLabel": {},
|
"@filterMimeImageLabel": {},
|
||||||
"filterMimeVideoLabel": "동영상",
|
"filterMimeVideoLabel": "동영상",
|
||||||
"@filterMimeVideoLabel": {},
|
"@filterMimeVideoLabel": {},
|
||||||
"coordinateFormatDms": "도분초",
|
"coordinateFormatDms": "도, 분, 초",
|
||||||
"@coordinateFormatDms": {},
|
"@coordinateFormatDms": {},
|
||||||
"coordinateFormatDecimal": "소수점",
|
"coordinateFormatDecimal": "십신 도",
|
||||||
"@coordinateFormatDecimal": {},
|
"@coordinateFormatDecimal": {},
|
||||||
"coordinateDms": "{direction} {coordinate}",
|
"coordinateDms": "{direction} {coordinate}",
|
||||||
"@coordinateDms": {},
|
"@coordinateDms": {},
|
||||||
|
@ -1406,5 +1406,13 @@
|
||||||
"appExportDynamicAlbums": "동적 앨범",
|
"appExportDynamicAlbums": "동적 앨범",
|
||||||
"@appExportDynamicAlbums": {},
|
"@appExportDynamicAlbums": {},
|
||||||
"chipActionDecompose": "나누기",
|
"chipActionDecompose": "나누기",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "도, 십진 분",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "시간 이동",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "GPX 가져오기",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"removeEntryMetadataDialogAll": "모두",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1408,5 +1408,13 @@
|
||||||
"chipActionRemove": "Verwijderen",
|
"chipActionRemove": "Verwijderen",
|
||||||
"@chipActionRemove": {},
|
"@chipActionRemove": {},
|
||||||
"chipActionDecompose": "Splitsen",
|
"chipActionDecompose": "Splitsen",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "GPX importeren",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Verschuiving van de tijd",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Alle",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1564,5 +1564,13 @@
|
||||||
"appExportDynamicAlbums": "Dynamiczne albumy",
|
"appExportDynamicAlbums": "Dynamiczne albumy",
|
||||||
"@appExportDynamicAlbums": {},
|
"@appExportDynamicAlbums": {},
|
||||||
"chipActionDecompose": "Podziel",
|
"chipActionDecompose": "Podziel",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Importuj GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Przesunięcie czasowe",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Wszystko",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1406,5 +1406,13 @@
|
||||||
"appExportDynamicAlbums": "Álbuns dinâmicos",
|
"appExportDynamicAlbums": "Álbuns dinâmicos",
|
||||||
"@appExportDynamicAlbums": {},
|
"@appExportDynamicAlbums": {},
|
||||||
"chipActionDecompose": "Separar",
|
"chipActionDecompose": "Separar",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Importar GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Salto temporal",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Todos",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1536,5 +1536,31 @@
|
||||||
"explorerPageTitle": "Explorer",
|
"explorerPageTitle": "Explorer",
|
||||||
"@explorerPageTitle": {},
|
"@explorerPageTitle": {},
|
||||||
"mapAttributionOsmData": "Datele hărții © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributori",
|
"mapAttributionOsmData": "Datele hărții © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributori",
|
||||||
"@mapAttributionOsmData": {}
|
"@mapAttributionOsmData": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Import GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"videoActionShowPreviousFrame": "Afișează cadrul anterior",
|
||||||
|
"@videoActionShowPreviousFrame": {},
|
||||||
|
"videoActionShowNextFrame": "Afișează următorul cadru",
|
||||||
|
"@videoActionShowNextFrame": {},
|
||||||
|
"mapAttributionOpenTopoMap": "[SRTM](https://www.earthdata.nasa.gov/sensors/srtm) | Plăci de la [OpenTopoMap](https://opentopomap.org/), [CC BY-SA](https://creativecommons.org/licenses/by-sa/3.0/)",
|
||||||
|
"@mapAttributionOpenTopoMap": {},
|
||||||
|
"chipActionDecompose": "Divizare",
|
||||||
|
"@chipActionDecompose": {},
|
||||||
|
"newDynamicAlbumDialogTitle": "Nou album dinamic",
|
||||||
|
"@newDynamicAlbumDialogTitle": {},
|
||||||
|
"dynamicAlbumAlreadyExists": "Albumul dinamic există deja",
|
||||||
|
"@dynamicAlbumAlreadyExists": {},
|
||||||
|
"collectionActionAddDynamicAlbum": "Adaugă album dinamic",
|
||||||
|
"@collectionActionAddDynamicAlbum": {},
|
||||||
|
"appExportDynamicAlbums": "Albume dinamice",
|
||||||
|
"@appExportDynamicAlbums": {},
|
||||||
|
"chipActionRemove": "Elimină",
|
||||||
|
"@chipActionRemove": {},
|
||||||
|
"albumTierDynamic": "Dinamic",
|
||||||
|
"@albumTierDynamic": {},
|
||||||
|
"newAlbumDialogAlbumAlreadyExistsHelper": "Albumul există deja",
|
||||||
|
"@newAlbumDialogAlbumAlreadyExistsHelper": {},
|
||||||
|
"mapAttributionOsmLiberty": "Plăci de la [OpenMapTiles](https://www.openmaptiles.org/), [CC BY](http://creativecommons.org/licenses/by/4.0) • Găzduit de [OSM Americana](https://tile.ourmap.us)",
|
||||||
|
"@mapAttributionOsmLiberty": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1563,6 +1563,14 @@
|
||||||
"@collectionActionAddDynamicAlbum": {},
|
"@collectionActionAddDynamicAlbum": {},
|
||||||
"appExportDynamicAlbums": "Динамічні альбоми",
|
"appExportDynamicAlbums": "Динамічні альбоми",
|
||||||
"@appExportDynamicAlbums": {},
|
"@appExportDynamicAlbums": {},
|
||||||
"chipActionDecompose": "Спліт",
|
"chipActionDecompose": "Розділити",
|
||||||
"@chipActionDecompose": {}
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {},
|
||||||
|
"editEntryLocationDialogImportGpx": "Імпорт GPX",
|
||||||
|
"@editEntryLocationDialogImportGpx": {},
|
||||||
|
"editEntryLocationDialogTimeShift": "Зсув часу",
|
||||||
|
"@editEntryLocationDialogTimeShift": {},
|
||||||
|
"removeEntryMetadataDialogAll": "Все",
|
||||||
|
"@removeEntryMetadataDialogAll": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1404,5 +1404,9 @@
|
||||||
"appExportDynamicAlbums": "动态专辑",
|
"appExportDynamicAlbums": "动态专辑",
|
||||||
"@appExportDynamicAlbums": {},
|
"@appExportDynamicAlbums": {},
|
||||||
"dynamicAlbumAlreadyExists": "动态专辑已存在",
|
"dynamicAlbumAlreadyExists": "动态专辑已存在",
|
||||||
"@dynamicAlbumAlreadyExists": {}
|
"@dynamicAlbumAlreadyExists": {},
|
||||||
|
"chipActionDecompose": "分割",
|
||||||
|
"@chipActionDecompose": {},
|
||||||
|
"coordinateFormatDdm": "DDM",
|
||||||
|
"@coordinateFormatDdm": {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -124,12 +124,14 @@ class Contributors {
|
||||||
Contributor('Grooty12', 'Rasmus@rosendahl-kaa.name'),
|
Contributor('Grooty12', 'Rasmus@rosendahl-kaa.name'),
|
||||||
Contributor('Victor M', 'victormorita@tuta.io'),
|
Contributor('Victor M', 'victormorita@tuta.io'),
|
||||||
Contributor('cat', 'catsnote@proton.me'),
|
Contributor('cat', 'catsnote@proton.me'),
|
||||||
|
Contributor('Bruno Fragoso', 'darth_signa@hotmail.com'),
|
||||||
// Contributor('Femini', 'nizamismidov4@gmail.com'), // Azerbaijani
|
// Contributor('Femini', 'nizamismidov4@gmail.com'), // Azerbaijani
|
||||||
// Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali
|
// Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali
|
||||||
// Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese
|
// Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese
|
||||||
// Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese
|
// Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese
|
||||||
// Contributor('Åzze', 'laitinen.jere222@gmail.com'), // Finnish
|
// Contributor('Åzze', 'laitinen.jere222@gmail.com'), // Finnish
|
||||||
// Contributor('Olli', 'ollinen@ollit.dev'), // Finnish
|
// Contributor('Olli', 'ollinen@ollit.dev'), // Finnish
|
||||||
|
// Contributor('Ricky Tigg', 'ricky.tigg@gmail.com'), // Finnish
|
||||||
// Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew
|
// Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew
|
||||||
// Contributor('Rohit Burman', 'rohitburman31p@rediffmail.com'), // Hindi
|
// Contributor('Rohit Burman', 'rohitburman31p@rediffmail.com'), // Hindi
|
||||||
// Contributor('AJ07', 'ajaykumarmeena676@gmail.com'), // Hindi
|
// Contributor('AJ07', 'ajaykumarmeena676@gmail.com'), // Hindi
|
||||||
|
|
|
@ -333,6 +333,11 @@ class Dependencies {
|
||||||
license: mit,
|
license: mit,
|
||||||
sourceUrl: 'https://github.com/fluttercommunity/get_it',
|
sourceUrl: 'https://github.com/fluttercommunity/get_it',
|
||||||
),
|
),
|
||||||
|
Dependency(
|
||||||
|
name: 'GPX',
|
||||||
|
license: apache2,
|
||||||
|
sourceUrl: 'https://github.com/kb0/dart-gpx',
|
||||||
|
),
|
||||||
Dependency(
|
Dependency(
|
||||||
name: 'HTTP',
|
name: 'HTTP',
|
||||||
license: bsd3,
|
license: bsd3,
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:aves/services/common/services.dart';
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:floating/floating.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
@ -57,13 +55,6 @@ class Device {
|
||||||
final auth = LocalAuthentication();
|
final auth = LocalAuthentication();
|
||||||
_canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported();
|
_canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported();
|
||||||
|
|
||||||
try {
|
|
||||||
_supportPictureInPicture = await Floating().isPipAvailable;
|
|
||||||
} on PlatformException catch (_) {
|
|
||||||
// as of floating v2.0.0, plugin assumes activity and fails when bound via service
|
|
||||||
_supportPictureInPicture = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final capabilities = await deviceService.getCapabilities();
|
final capabilities = await deviceService.getCapabilities();
|
||||||
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
|
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
|
||||||
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
|
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
|
||||||
|
@ -74,5 +65,6 @@ class Device {
|
||||||
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
|
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
|
||||||
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
|
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
|
||||||
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false;
|
_supportEdgeToEdgeUIMode = capabilities['supportEdgeToEdgeUIMode'] ?? false;
|
||||||
|
_supportPictureInPicture = capabilities['supportPictureInPicture'] ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:ui';
|
||||||
|
|
||||||
import 'package:aves/model/entry/cache.dart';
|
import 'package:aves/model/entry/cache.dart';
|
||||||
import 'package:aves/model/entry/dirs.dart';
|
import 'package:aves/model/entry/dirs.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/keys.dart';
|
||||||
import 'package:aves/model/metadata/address.dart';
|
import 'package:aves/model/metadata/address.dart';
|
||||||
import 'package:aves/model/metadata/catalog.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/metadata/trash.dart';
|
import 'package:aves/model/metadata/trash.dart';
|
||||||
|
@ -127,63 +128,63 @@ class AvesEntry with AvesEntryBase {
|
||||||
// from DB or platform source entry
|
// from DB or platform source entry
|
||||||
factory AvesEntry.fromMap(Map map) {
|
factory AvesEntry.fromMap(Map map) {
|
||||||
return AvesEntry(
|
return AvesEntry(
|
||||||
id: map['id'] as int?,
|
id: map[EntryFields.id] as int?,
|
||||||
uri: map['uri'] as String,
|
uri: map[EntryFields.uri] as String,
|
||||||
path: map['path'] as String?,
|
path: map[EntryFields.path] as String?,
|
||||||
pageId: null,
|
pageId: null,
|
||||||
contentId: map['contentId'] as int?,
|
contentId: map[EntryFields.contentId] as int?,
|
||||||
sourceMimeType: map['sourceMimeType'] as String,
|
sourceMimeType: map[EntryFields.sourceMimeType] as String,
|
||||||
width: map['width'] as int? ?? 0,
|
width: map[EntryFields.width] as int? ?? 0,
|
||||||
height: map['height'] as int? ?? 0,
|
height: map[EntryFields.height] as int? ?? 0,
|
||||||
sourceRotationDegrees: map['sourceRotationDegrees'] as int? ?? 0,
|
sourceRotationDegrees: map[EntryFields.sourceRotationDegrees] as int? ?? 0,
|
||||||
sizeBytes: map['sizeBytes'] as int?,
|
sizeBytes: map[EntryFields.sizeBytes] as int?,
|
||||||
sourceTitle: map['title'] as String?,
|
sourceTitle: map[EntryFields.title] as String?,
|
||||||
dateAddedSecs: map['dateAddedSecs'] as int?,
|
dateAddedSecs: map[EntryFields.dateAddedSecs] as int?,
|
||||||
dateModifiedSecs: map['dateModifiedSecs'] as int?,
|
dateModifiedSecs: map[EntryFields.dateModifiedSecs] as int?,
|
||||||
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
|
sourceDateTakenMillis: map[EntryFields.sourceDateTakenMillis] as int?,
|
||||||
durationMillis: map['durationMillis'] as int?,
|
durationMillis: map[EntryFields.durationMillis] as int?,
|
||||||
trashed: (map['trashed'] as int? ?? 0) != 0,
|
trashed: (map[EntryFields.trashed] as int? ?? 0) != 0,
|
||||||
origin: map['origin'] as int,
|
origin: map[EntryFields.origin] as int,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// for DB only
|
// for DB only
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
EntryFields.id: id,
|
||||||
'uri': uri,
|
EntryFields.uri: uri,
|
||||||
'path': path,
|
EntryFields.path: path,
|
||||||
'contentId': contentId,
|
EntryFields.contentId: contentId,
|
||||||
'sourceMimeType': sourceMimeType,
|
EntryFields.sourceMimeType: sourceMimeType,
|
||||||
'width': width,
|
EntryFields.width: width,
|
||||||
'height': height,
|
EntryFields.height: height,
|
||||||
'sourceRotationDegrees': sourceRotationDegrees,
|
EntryFields.sourceRotationDegrees: sourceRotationDegrees,
|
||||||
'sizeBytes': sizeBytes,
|
EntryFields.sizeBytes: sizeBytes,
|
||||||
'title': sourceTitle,
|
EntryFields.title: sourceTitle,
|
||||||
'dateAddedSecs': dateAddedSecs,
|
EntryFields.dateAddedSecs: dateAddedSecs,
|
||||||
'dateModifiedSecs': dateModifiedSecs,
|
EntryFields.dateModifiedSecs: dateModifiedSecs,
|
||||||
'sourceDateTakenMillis': sourceDateTakenMillis,
|
EntryFields.sourceDateTakenMillis: sourceDateTakenMillis,
|
||||||
'durationMillis': durationMillis,
|
EntryFields.durationMillis: durationMillis,
|
||||||
'trashed': trashed ? 1 : 0,
|
EntryFields.trashed: trashed ? 1 : 0,
|
||||||
'origin': origin,
|
EntryFields.origin: origin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toPlatformEntryMap() {
|
Map<String, dynamic> toPlatformEntryMap() {
|
||||||
return {
|
return {
|
||||||
'uri': uri,
|
EntryFields.uri: uri,
|
||||||
'path': path,
|
EntryFields.path: path,
|
||||||
'pageId': pageId,
|
EntryFields.pageId: pageId,
|
||||||
'mimeType': mimeType,
|
EntryFields.mimeType: mimeType,
|
||||||
'width': width,
|
EntryFields.width: width,
|
||||||
'height': height,
|
EntryFields.height: height,
|
||||||
'rotationDegrees': rotationDegrees,
|
EntryFields.rotationDegrees: rotationDegrees,
|
||||||
'isFlipped': isFlipped,
|
EntryFields.isFlipped: isFlipped,
|
||||||
'dateModifiedSecs': dateModifiedSecs,
|
EntryFields.dateModifiedSecs: dateModifiedSecs,
|
||||||
'sizeBytes': sizeBytes,
|
EntryFields.sizeBytes: sizeBytes,
|
||||||
'trashed': trashed,
|
EntryFields.trashed: trashed,
|
||||||
'trashPath': trashDetails?.path,
|
EntryFields.trashPath: trashDetails?.path,
|
||||||
'origin': origin,
|
EntryFields.origin: origin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -402,34 +403,34 @@ class AvesEntry with AvesEntryBase {
|
||||||
final oldRotationDegrees = this.rotationDegrees;
|
final oldRotationDegrees = this.rotationDegrees;
|
||||||
final oldIsFlipped = this.isFlipped;
|
final oldIsFlipped = this.isFlipped;
|
||||||
|
|
||||||
final uri = newFields['uri'];
|
final uri = newFields[EntryFields.uri];
|
||||||
if (uri is String) this.uri = uri;
|
if (uri is String) this.uri = uri;
|
||||||
final path = newFields['path'];
|
final path = newFields[EntryFields.path];
|
||||||
if (path is String) this.path = path;
|
if (path is String) this.path = path;
|
||||||
final contentId = newFields['contentId'];
|
final contentId = newFields[EntryFields.contentId];
|
||||||
if (contentId is int) this.contentId = contentId;
|
if (contentId is int) this.contentId = contentId;
|
||||||
|
|
||||||
final sourceTitle = newFields['title'];
|
final sourceTitle = newFields[EntryFields.title];
|
||||||
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
if (sourceTitle is String) this.sourceTitle = sourceTitle;
|
||||||
final sourceRotationDegrees = newFields['sourceRotationDegrees'];
|
final sourceRotationDegrees = newFields[EntryFields.sourceRotationDegrees];
|
||||||
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
|
if (sourceRotationDegrees is int) this.sourceRotationDegrees = sourceRotationDegrees;
|
||||||
final sourceDateTakenMillis = newFields['sourceDateTakenMillis'];
|
final sourceDateTakenMillis = newFields[EntryFields.sourceDateTakenMillis];
|
||||||
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
|
if (sourceDateTakenMillis is int) this.sourceDateTakenMillis = sourceDateTakenMillis;
|
||||||
|
|
||||||
final width = newFields['width'];
|
final width = newFields[EntryFields.width];
|
||||||
if (width is int) this.width = width;
|
if (width is int) this.width = width;
|
||||||
final height = newFields['height'];
|
final height = newFields[EntryFields.height];
|
||||||
if (height is int) this.height = height;
|
if (height is int) this.height = height;
|
||||||
final durationMillis = newFields['durationMillis'];
|
final durationMillis = newFields[EntryFields.durationMillis];
|
||||||
if (durationMillis is int) this.durationMillis = durationMillis;
|
if (durationMillis is int) this.durationMillis = durationMillis;
|
||||||
|
|
||||||
final sizeBytes = newFields['sizeBytes'];
|
final sizeBytes = newFields[EntryFields.sizeBytes];
|
||||||
if (sizeBytes is int) this.sizeBytes = sizeBytes;
|
if (sizeBytes is int) this.sizeBytes = sizeBytes;
|
||||||
final dateModifiedSecs = newFields['dateModifiedSecs'];
|
final dateModifiedSecs = newFields[EntryFields.dateModifiedSecs];
|
||||||
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs;
|
||||||
final rotationDegrees = newFields['rotationDegrees'];
|
final rotationDegrees = newFields[EntryFields.rotationDegrees];
|
||||||
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
if (rotationDegrees is int) this.rotationDegrees = rotationDegrees;
|
||||||
final isFlipped = newFields['isFlipped'];
|
final isFlipped = newFields[EntryFields.isFlipped];
|
||||||
if (isFlipped is bool) this.isFlipped = isFlipped;
|
if (isFlipped is bool) this.isFlipped = isFlipped;
|
||||||
|
|
||||||
if (persist) {
|
if (persist) {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/keys.dart';
|
||||||
import 'package:aves/model/entry/extensions/props.dart';
|
import 'package:aves/model/entry/extensions/props.dart';
|
||||||
import 'package:aves/model/media/geotiff.dart';
|
import 'package:aves/model/media/geotiff.dart';
|
||||||
import 'package:aves/model/metadata/catalog.dart';
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
|
@ -22,20 +23,20 @@ extension ExtraAvesEntryCatalog on AvesEntry {
|
||||||
final size = await SvgMetadataService.getSize(this);
|
final size = await SvgMetadataService.getSize(this);
|
||||||
if (size != null) {
|
if (size != null) {
|
||||||
final fields = {
|
final fields = {
|
||||||
'width': size.width.ceil(),
|
EntryFields.width: size.width.ceil(),
|
||||||
'height': size.height.ceil(),
|
EntryFields.height: size.height.ceil(),
|
||||||
};
|
};
|
||||||
await applyNewFields(fields, persist: persist);
|
await applyNewFields(fields, persist: persist);
|
||||||
}
|
}
|
||||||
catalogMetadata = CatalogMetadata(id: id);
|
catalogMetadata = CatalogMetadata(id: id);
|
||||||
} else {
|
} else {
|
||||||
// pre-processing
|
// pre-processing
|
||||||
if ((isVideo && (!isSized || durationMillis == 0)) || mimeType == MimeTypes.avif) {
|
if (isVideo || mimeType == MimeTypes.avif) {
|
||||||
// exotic video that is not sized during loading
|
// on initial loading, original source may report incorrect size, rotation or duration
|
||||||
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
|
final fields = await VideoMetadataFormatter.getLoadingMetadata(this);
|
||||||
// check size as the video interpreter may fail on some AVIF stills
|
// check size as the video interpreter may fail on some AVIF stills
|
||||||
final width = fields['width'];
|
final width = fields[EntryFields.width];
|
||||||
final height = fields['height'];
|
final height = fields[EntryFields.height];
|
||||||
final isValid = (width == null || width > 0) && (height == null || height > 0);
|
final isValid = (width == null || width > 0) && (height == null || height > 0);
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
await applyNewFields(fields, persist: persist);
|
await applyNewFields(fields, persist: persist);
|
||||||
|
@ -47,7 +48,7 @@ extension ExtraAvesEntryCatalog on AvesEntry {
|
||||||
|
|
||||||
// post-processing
|
// post-processing
|
||||||
if ((isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) || (mimeType == MimeTypes.avif && durationMillis != null)) {
|
if ((isVideo && (catalogMetadata?.dateMillis ?? 0) == 0) || (mimeType == MimeTypes.avif && durationMillis != null)) {
|
||||||
catalogMetadata = await VideoMetadataFormatter.getCatalogMetadata(this);
|
catalogMetadata = await VideoMetadataFormatter.completeCatalogMetadata(this);
|
||||||
}
|
}
|
||||||
if (isGeotiff && !hasGps) {
|
if (isGeotiff && !hasGps) {
|
||||||
final info = await metadataFetchService.getGeoTiffInfo(this);
|
final info = await metadataFetchService.getGeoTiffInfo(this);
|
||||||
|
|
|
@ -67,13 +67,13 @@ extension ExtraAvesEntryImages on AvesEntry {
|
||||||
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
|
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
|
||||||
}
|
}
|
||||||
|
|
||||||
// magic number used to derive sample size from scale
|
static int sampleSizeForScale({
|
||||||
static const scaleFactor = 2.0;
|
required double magnifierScale,
|
||||||
|
required double devicePixelRatio,
|
||||||
static int sampleSizeForScale(double scale) {
|
}) {
|
||||||
var sample = 0;
|
var sample = 0;
|
||||||
if (0 < scale && scale < 1) {
|
if (0 < magnifierScale && magnifierScale < 1) {
|
||||||
sample = highestPowerOf2((1 / scale) / scaleFactor);
|
sample = highestPowerOf2(1 / (magnifierScale * devicePixelRatio));
|
||||||
}
|
}
|
||||||
return max<int>(1, sample);
|
return max<int>(1, sample);
|
||||||
}
|
}
|
||||||
|
|
28
lib/model/entry/extensions/keys.dart
Normal file
28
lib/model/entry/extensions/keys.dart
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// entry fields exported and imported from/to the platform side
|
||||||
|
// should match `EntryFields` on platform side
|
||||||
|
class EntryFields {
|
||||||
|
static const id = 'id'; // int
|
||||||
|
static const origin = 'origin'; // int
|
||||||
|
static const uri = 'uri'; // string
|
||||||
|
static const contentId = 'contentId'; // long
|
||||||
|
static const path = 'path'; // string
|
||||||
|
static const pageId = 'pageId'; // int
|
||||||
|
static const sourceMimeType = 'sourceMimeType'; // string
|
||||||
|
static const mimeType = 'mimeType'; // string
|
||||||
|
|
||||||
|
static const width = 'width'; // int
|
||||||
|
static const height = 'height'; // int
|
||||||
|
static const sourceRotationDegrees = 'sourceRotationDegrees'; // int
|
||||||
|
static const rotationDegrees = 'rotationDegrees'; // int
|
||||||
|
static const isFlipped = 'isFlipped'; // boolean
|
||||||
|
|
||||||
|
static const dateAddedSecs = 'dateAddedSecs'; // long
|
||||||
|
static const dateModifiedSecs = 'dateModifiedSecs'; // long
|
||||||
|
static const sourceDateTakenMillis = 'sourceDateTakenMillis'; // long
|
||||||
|
static const durationMillis = 'durationMillis'; // long
|
||||||
|
|
||||||
|
static const sizeBytes = 'sizeBytes'; // long
|
||||||
|
static const trashed = 'trashed'; // boolean
|
||||||
|
static const trashPath = 'trashPath'; // string
|
||||||
|
static const title = 'title'; // string
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/keys.dart';
|
||||||
import 'package:aves/model/media/video/channel_layouts.dart';
|
import 'package:aves/model/media/video/channel_layouts.dart';
|
||||||
import 'package:aves/model/media/video/codecs.dart';
|
import 'package:aves/model/media/video/codecs.dart';
|
||||||
import 'package:aves/model/media/video/profiles/aac.dart';
|
import 'package:aves/model/media/video/profiles/aac.dart';
|
||||||
|
@ -46,6 +47,7 @@ class VideoMetadataFormatter {
|
||||||
Codecs.webm: 'WebM',
|
Codecs.webm: 'WebM',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// fetch size, rotation and duration
|
||||||
static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async {
|
static Future<Map<String, int>> getLoadingMetadata(AvesEntry entry) async {
|
||||||
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
|
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
|
||||||
final fields = <String, int>{};
|
final fields = <String, int>{};
|
||||||
|
@ -58,25 +60,31 @@ class VideoMetadataFormatter {
|
||||||
final width = sizedStream[Keys.videoWidth];
|
final width = sizedStream[Keys.videoWidth];
|
||||||
final height = sizedStream[Keys.videoHeight];
|
final height = sizedStream[Keys.videoHeight];
|
||||||
if (width is int && height is int) {
|
if (width is int && height is int) {
|
||||||
fields['width'] = width;
|
fields[EntryFields.width] = width;
|
||||||
fields['height'] = height;
|
fields[EntryFields.height] = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rotationDegrees = sizedStream[Keys.rotate];
|
||||||
|
if (rotationDegrees is int) {
|
||||||
|
fields[EntryFields.rotationDegrees] = rotationDegrees;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final durationMicros = mediaInfo[Keys.durationMicros];
|
final durationMicros = mediaInfo[Keys.durationMicros];
|
||||||
if (durationMicros is num) {
|
if (durationMicros is num) {
|
||||||
fields['durationMillis'] = (durationMicros / 1000).round();
|
fields[EntryFields.durationMillis] = (durationMicros / 1000).round();
|
||||||
} else {
|
} else {
|
||||||
final duration = _parseDuration(mediaInfo[Keys.duration]);
|
final duration = _parseDuration(mediaInfo[Keys.duration]);
|
||||||
if (duration != null && duration > Duration.zero) {
|
if (duration != null && duration > Duration.zero) {
|
||||||
fields['durationMillis'] = duration.inMilliseconds;
|
fields[EntryFields.durationMillis] = duration.inMilliseconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fields;
|
return fields;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<CatalogMetadata?> getCatalogMetadata(AvesEntry entry) async {
|
// fetch date and animated status
|
||||||
|
static Future<CatalogMetadata?> completeCatalogMetadata(AvesEntry entry) async {
|
||||||
var catalogMetadata = entry.catalogMetadata ?? CatalogMetadata(id: entry.id);
|
var catalogMetadata = entry.catalogMetadata ?? CatalogMetadata(id: entry.id);
|
||||||
|
|
||||||
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
|
final mediaInfo = await videoMetadataFetcher.getMetadata(entry);
|
||||||
|
|
7
lib/model/settings/modules/debug.dart
Normal file
7
lib/model/settings/modules/debug.dart
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import 'package:aves_model/aves_model.dart';
|
||||||
|
|
||||||
|
mixin DebugSettings on SettingsAccess {
|
||||||
|
bool get debugShowViewerTiles => getBool(SettingKeys.debugShowViewerTilesKey) ?? false;
|
||||||
|
|
||||||
|
set debugShowViewerTiles(bool newValue) => set(SettingKeys.debugShowViewerTilesKey, newValue);
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
||||||
import 'package:aves/model/settings/enums/map_style.dart';
|
import 'package:aves/model/settings/enums/map_style.dart';
|
||||||
import 'package:aves/model/settings/modules/app.dart';
|
import 'package:aves/model/settings/modules/app.dart';
|
||||||
import 'package:aves/model/settings/modules/collection.dart';
|
import 'package:aves/model/settings/modules/collection.dart';
|
||||||
|
import 'package:aves/model/settings/modules/debug.dart';
|
||||||
import 'package:aves/model/settings/modules/display.dart';
|
import 'package:aves/model/settings/modules/display.dart';
|
||||||
import 'package:aves/model/settings/modules/filter_grids.dart';
|
import 'package:aves/model/settings/modules/filter_grids.dart';
|
||||||
import 'package:aves/model/settings/modules/info.dart';
|
import 'package:aves/model/settings/modules/info.dart';
|
||||||
|
@ -40,7 +41,7 @@ import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
final Settings settings = Settings._private();
|
final Settings settings = Settings._private();
|
||||||
|
|
||||||
class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings, NavigationSettings, SearchSettings, CollectionSettings, FilterGridsSettings, PrivacySettings, ViewerSettings, VideoSettings, SubtitlesSettings, InfoSettings {
|
class Settings with ChangeNotifier, SettingsAccess, DebugSettings, AppSettings, DisplaySettings, NavigationSettings, SearchSettings, CollectionSettings, FilterGridsSettings, PrivacySettings, ViewerSettings, VideoSettings, SubtitlesSettings, InfoSettings {
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
|
final EventChannel _platformSettingsChangeChannel = const OptionalEventChannel('deckers.thibault/aves/settings_change');
|
||||||
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
|
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController.broadcast();
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'dart:ui';
|
||||||
import 'package:aves/model/covers.dart';
|
import 'package:aves/model/covers.dart';
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
import 'package:aves/model/entry/extensions/catalog.dart';
|
import 'package:aves/model/entry/extensions/catalog.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/keys.dart';
|
||||||
import 'package:aves/model/entry/extensions/location.dart';
|
import 'package:aves/model/entry/extensions/location.dart';
|
||||||
import 'package:aves/model/entry/sort.dart';
|
import 'package:aves/model/entry/sort.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
|
@ -242,29 +243,29 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
newFields.keys.forEach((key) {
|
newFields.keys.forEach((key) {
|
||||||
final newValue = newFields[key];
|
final newValue = newFields[key];
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'contentId':
|
case EntryFields.contentId:
|
||||||
entry.contentId = newValue as int?;
|
entry.contentId = newValue as int?;
|
||||||
case 'dateModifiedSecs':
|
case EntryFields.dateModifiedSecs:
|
||||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||||
// but it does not change when renaming the containing directory
|
// but it does not change when renaming the containing directory
|
||||||
entry.dateModifiedSecs = newValue as int?;
|
entry.dateModifiedSecs = newValue as int?;
|
||||||
case 'path':
|
case EntryFields.path:
|
||||||
entry.path = newValue as String?;
|
entry.path = newValue as String?;
|
||||||
case 'title':
|
case EntryFields.title:
|
||||||
entry.sourceTitle = newValue as String?;
|
entry.sourceTitle = newValue as String?;
|
||||||
case 'trashed':
|
case EntryFields.trashed:
|
||||||
final trashed = newValue as bool;
|
final trashed = newValue as bool;
|
||||||
entry.trashed = trashed;
|
entry.trashed = trashed;
|
||||||
entry.trashDetails = trashed
|
entry.trashDetails = trashed
|
||||||
? TrashDetails(
|
? TrashDetails(
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
path: newFields['trashPath'] as String,
|
path: newFields[EntryFields.trashPath] as String,
|
||||||
dateMillis: DateTime.now().millisecondsSinceEpoch,
|
dateMillis: DateTime.now().millisecondsSinceEpoch,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
case 'uri':
|
case EntryFields.uri:
|
||||||
entry.uri = newValue as String;
|
entry.uri = newValue as String;
|
||||||
case 'origin':
|
case EntryFields.origin:
|
||||||
entry.origin = newValue as int;
|
entry.origin = newValue as int;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -341,7 +342,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
if (movedOps.isEmpty) return;
|
if (movedOps.isEmpty) return;
|
||||||
|
|
||||||
final replacedUris = movedOps
|
final replacedUris = movedOps
|
||||||
.map((movedOp) => movedOp.newFields['path'] as String?)
|
.map((movedOp) => movedOp.newFields[EntryFields.path] as String?)
|
||||||
.map((targetPath) {
|
.map((targetPath) {
|
||||||
final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed);
|
final existingEntry = _rawEntries.firstWhereOrNull((entry) => entry.path == targetPath && !entry.trashed);
|
||||||
return existingEntry?.uri;
|
return existingEntry?.uri;
|
||||||
|
@ -362,14 +363,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
fromAlbums.add(sourceEntry.directory);
|
fromAlbums.add(sourceEntry.directory);
|
||||||
movedEntries.add(sourceEntry.copyWith(
|
movedEntries.add(sourceEntry.copyWith(
|
||||||
id: localMediaDb.nextId,
|
id: localMediaDb.nextId,
|
||||||
uri: newFields['uri'] as String?,
|
uri: newFields[EntryFields.uri] as String?,
|
||||||
path: newFields['path'] as String?,
|
path: newFields[EntryFields.path] as String?,
|
||||||
contentId: newFields['contentId'] as int?,
|
contentId: newFields[EntryFields.contentId] as int?,
|
||||||
// title can change when moved files are automatically renamed to avoid conflict
|
// title can change when moved files are automatically renamed to avoid conflict
|
||||||
title: newFields['title'] as String?,
|
title: newFields[EntryFields.title] as String?,
|
||||||
dateAddedSecs: newFields['dateAddedSecs'] as int?,
|
dateAddedSecs: newFields[EntryFields.dateAddedSecs] as int?,
|
||||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
dateModifiedSecs: newFields[EntryFields.dateModifiedSecs] as int?,
|
||||||
origin: newFields['origin'] as int?,
|
origin: newFields[EntryFields.origin] as int?,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
debugPrint('failed to find source entry with uri=$sourceUri');
|
debugPrint('failed to find source entry with uri=$sourceUri');
|
||||||
|
@ -386,7 +387,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
|
||||||
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
|
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
if (moveType == MoveType.fromBin) {
|
if (moveType == MoveType.fromBin) {
|
||||||
newFields['trashed'] = false;
|
newFields[EntryFields.trashed] = false;
|
||||||
} else {
|
} else {
|
||||||
fromAlbums.add(entry.directory);
|
fromAlbums.add(entry.directory);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,11 +11,18 @@ String formatDateTime(DateTime date, String locale, bool use24hour) => [
|
||||||
].join(AText.separator);
|
].join(AText.separator);
|
||||||
|
|
||||||
String formatFriendlyDuration(Duration d) {
|
String formatFriendlyDuration(Duration d) {
|
||||||
final seconds = (d.inSeconds.remainder(Duration.secondsPerMinute)).toString().padLeft(2, '0');
|
final isNegative = d.isNegative;
|
||||||
if (d.inHours == 0) return '${d.inMinutes}:$seconds';
|
final sign = isNegative ? '-' : '';
|
||||||
|
d = d.abs();
|
||||||
|
final hours = d.inHours;
|
||||||
|
d -= Duration(hours: hours);
|
||||||
|
final minutes = d.inMinutes;
|
||||||
|
d -= Duration(minutes: minutes);
|
||||||
|
final seconds = d.inSeconds;
|
||||||
|
|
||||||
final minutes = (d.inMinutes.remainder(Duration.minutesPerHour)).toString().padLeft(2, '0');
|
if (hours == 0) return '$sign$minutes:${seconds.toString().padLeft(2, '0')}';
|
||||||
return '${d.inHours}:$minutes:$seconds';
|
|
||||||
|
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|
||||||
String formatPreciseDuration(Duration d) {
|
String formatPreciseDuration(Duration d) {
|
||||||
|
|
|
@ -9,6 +9,7 @@ extension ExtraLocationEditActionView on LocationEditAction {
|
||||||
LocationEditAction.chooseOnMap => l10n.editEntryLocationDialogChooseOnMap,
|
LocationEditAction.chooseOnMap => l10n.editEntryLocationDialogChooseOnMap,
|
||||||
LocationEditAction.copyItem => l10n.editEntryDialogCopyFromItem,
|
LocationEditAction.copyItem => l10n.editEntryDialogCopyFromItem,
|
||||||
LocationEditAction.setCustom => l10n.editEntryLocationDialogSetCustom,
|
LocationEditAction.setCustom => l10n.editEntryLocationDialogSetCustom,
|
||||||
|
LocationEditAction.importGpx => l10n.editEntryLocationDialogImportGpx,
|
||||||
LocationEditAction.remove => l10n.actionRemove,
|
LocationEditAction.remove => l10n.actionRemove,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,10 +154,12 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
||||||
final flavor = context.read<AppFlavor>().toString().split('.')[1];
|
final flavor = context.read<AppFlavor>().toString().split('.')[1];
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final view = View.of(context);
|
||||||
|
final supportsHdr = await windowService.supportsHdr();
|
||||||
final connections = await Connectivity().checkConnectivity();
|
final connections = await Connectivity().checkConnectivity();
|
||||||
final storageVolumes = await storageService.getStorageVolumes();
|
final storageVolumes = await storageService.getStorageVolumes();
|
||||||
final storageGrants = await storageService.getGrantedDirectories();
|
final storageGrants = await storageService.getGrantedDirectories();
|
||||||
final supportsHdr = await windowService.supportsHdr();
|
|
||||||
return [
|
return [
|
||||||
'Package: ${device.packageName}',
|
'Package: ${device.packageName}',
|
||||||
'Installer: ${packageInfo.installerStore}',
|
'Installer: ${packageInfo.installerStore}',
|
||||||
|
@ -166,6 +168,7 @@ class _BugReportState extends State<BugReport> with FeedbackMixin {
|
||||||
'Android version: ${androidInfo.version.release}, API ${androidInfo.version.sdkInt}',
|
'Android version: ${androidInfo.version.release}, API ${androidInfo.version.sdkInt}',
|
||||||
'Android build: ${androidInfo.display}',
|
'Android build: ${androidInfo.display}',
|
||||||
'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
|
'Device: ${androidInfo.manufacturer} ${androidInfo.model}',
|
||||||
|
'Display: pixel ratio=${view.devicePixelRatio}, logical=${mediaQuery.size.width}x${mediaQuery.size.height}, physical=${view.physicalSize.width}x${view.physicalSize.height}',
|
||||||
'Support: dynamic colors=${device.isDynamicColorAvailable}, geocoder=${device.hasGeocoder}, HDR=$supportsHdr',
|
'Support: dynamic colors=${device.isDynamicColorAvailable}, geocoder=${device.hasGeocoder}, HDR=$supportsHdr',
|
||||||
'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}',
|
'Mobile services: ${mobileServices.isServiceAvailable ? 'ready' : 'not available'}',
|
||||||
'Connectivity: ${connections.map((v) => v.name).join(', ')}',
|
'Connectivity: ${connections.map((v) => v.name).join(', ')}',
|
||||||
|
|
|
@ -152,10 +152,8 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeMetrics() {
|
void didChangeMetrics() {
|
||||||
// when top padding changes
|
// when top padding or text scale factor change
|
||||||
_updateStatusBarHeight();
|
WidgetsBinding.instance.addPostFrameCallback((_) => _updateStatusBarHeight());
|
||||||
// when text scale factor changes
|
|
||||||
_updateAppBarHeight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
@ -292,26 +292,29 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
final details = vaults.getVault(entry.directory);
|
final details = vaults.getVault(entry.directory);
|
||||||
return details?.useBin ?? settings.enableBin;
|
return details?.useBin ?? settings.enableBin;
|
||||||
});
|
});
|
||||||
await Future.forEach(
|
var completed = true;
|
||||||
byBinUsage.entries,
|
await Future.forEach(byBinUsage.entries, (kv) async {
|
||||||
(kv) => doDelete(
|
completed &= await doDelete(
|
||||||
context: context,
|
context: context,
|
||||||
entries: kv.value.toSet(),
|
entries: kv.value.toSet(),
|
||||||
enableBin: kv.key,
|
enableBin: kv.key,
|
||||||
));
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
_browse(context);
|
_browse(context);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> doDelete({
|
// returns whether it completed the action (with or without failures)
|
||||||
|
Future<bool> doDelete({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required Set<AvesEntry> entries,
|
required Set<AvesEntry> entries,
|
||||||
required bool enableBin,
|
required bool enableBin,
|
||||||
}) async {
|
}) async {
|
||||||
final pureTrash = entries.every((entry) => entry.trashed);
|
final pureTrash = entries.every((entry) => entry.trashed);
|
||||||
if (enableBin && !pureTrash) {
|
if (enableBin && !pureTrash) {
|
||||||
await doMove(context, moveType: MoveType.toBin, entries: entries);
|
return await doMove(context, moveType: MoveType.toBin, entries: entries);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
|
@ -325,10 +328,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
message: l10n.deleteEntriesConfirmationDialogMessage(todoCount),
|
message: l10n.deleteEntriesConfirmationDialogMessage(todoCount),
|
||||||
confirmationButtonLabel: l10n.deleteButtonLabel,
|
confirmationButtonLabel: l10n.deleteButtonLabel,
|
||||||
)) {
|
)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await checkStoragePermissionForAlbums(context, storageDirs, entries: entries)) return;
|
if (!await checkStoragePermissionForAlbums(context, storageDirs, entries: entries)) return false;
|
||||||
|
|
||||||
source.pauseMonitoring();
|
source.pauseMonitoring();
|
||||||
final opId = mediaEditService.newOpId;
|
final opId = mediaEditService.newOpId;
|
||||||
|
@ -338,9 +341,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onCancel: () => mediaEditService.cancelFileOp(opId),
|
onCancel: () => mediaEditService.cancelFileOp(opId),
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final successOps = processed.where((e) => e.success).toSet();
|
final successOps = processed.where((op) => op.success).toSet();
|
||||||
final deletedOps = successOps.where((e) => !e.skipped).toSet();
|
final deletedOps = successOps.where((op) => !op.skipped).toSet();
|
||||||
final deletedUris = deletedOps.map((event) => event.uri).toSet();
|
final deletedUris = deletedOps.map((op) => op.uri).toSet();
|
||||||
await source.removeEntries(deletedUris, includeTrash: true);
|
await source.removeEntries(deletedUris, includeTrash: true);
|
||||||
source.resumeMonitoring();
|
source.resumeMonitoring();
|
||||||
|
|
||||||
|
@ -354,14 +357,17 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
await storageService.deleteEmptyRegularDirectories(storageDirs);
|
await storageService.deleteEmptyRegularDirectories(storageDirs);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
|
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
|
||||||
final entries = _getTargetItems(context);
|
final entries = _getTargetItems(context);
|
||||||
await doMove(context, moveType: moveType, entries: entries);
|
final completed = await doMove(context, moveType: moveType, entries: entries);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
_browse(context);
|
_browse(context);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _rename(BuildContext context) async {
|
Future<void> _rename(BuildContext context) async {
|
||||||
final entries = _getTargetItems(context).toList();
|
final entries = _getTargetItems(context).toList();
|
||||||
|
@ -381,10 +387,12 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
return MapEntry(entry, '$newName${entry.extension}');
|
return MapEntry(entry, '$newName${entry.extension}');
|
||||||
});
|
});
|
||||||
final entriesToNewName = Map.fromEntries(await Future.wait(namingFutures)).whereNotNullValue();
|
final entriesToNewName = Map.fromEntries(await Future.wait(namingFutures)).whereNotNullValue();
|
||||||
await rename(context, entriesToNewName: entriesToNewName, persist: true);
|
final completed = await rename(context, entriesToNewName: entriesToNewName, persist: true);
|
||||||
|
|
||||||
|
if (completed) {
|
||||||
_browse(context);
|
_browse(context);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _convert(BuildContext context) async {
|
Future<void> _convert(BuildContext context) async {
|
||||||
final entries = _getTargetItems(context);
|
final entries = _getTargetItems(context);
|
||||||
|
@ -398,13 +406,14 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
|
|
||||||
switch (options.action) {
|
switch (options.action) {
|
||||||
case EntryConvertAction.convert:
|
case EntryConvertAction.convert:
|
||||||
await doExport(context, entries, options);
|
final completed = await doExport(context, entries, options);
|
||||||
|
if (completed) {
|
||||||
|
_browse(context);
|
||||||
|
}
|
||||||
case EntryConvertAction.convertMotionPhotoToStillImage:
|
case EntryConvertAction.convertMotionPhotoToStillImage:
|
||||||
final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet();
|
final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet();
|
||||||
await _edit(context, todoItems, (entry) => entry.removeTrailerVideo());
|
await _edit(context, todoItems, (entry) => entry.removeTrailerVideo());
|
||||||
}
|
}
|
||||||
|
|
||||||
_browse(context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleFavourite(BuildContext context) async {
|
Future<void> _toggleFavourite(BuildContext context) async {
|
||||||
|
@ -451,11 +460,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onCancel: () => cancelled = true,
|
onCancel: () => cancelled = true,
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final successOps = processed.where((e) => e.success).toSet();
|
final successOps = processed.where((op) => op.success).toSet();
|
||||||
final editedOps = successOps.where((e) => !e.skipped).toSet();
|
final editedOps = successOps.where((op) => !op.skipped).toSet();
|
||||||
source.resumeMonitoring();
|
source.resumeMonitoring();
|
||||||
|
|
||||||
unawaited(source.refreshUris(editedOps.map((v) => v.uri).toSet()).then((_) {
|
unawaited(source.refreshUris(editedOps.map((op) => op.uri).toSet()).then((_) {
|
||||||
// invalidate filters derived from values before edition
|
// invalidate filters derived from values before edition
|
||||||
// this invalidation must happen after the source is refreshed,
|
// this invalidation must happen after the source is refreshed,
|
||||||
// otherwise filter chips may eagerly rebuild in between with the old state
|
// otherwise filter chips may eagerly rebuild in between with the old state
|
||||||
|
@ -563,10 +572,10 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
if (entries == null || entries.isEmpty) return;
|
if (entries == null || entries.isEmpty) return;
|
||||||
|
|
||||||
final collection = context.read<CollectionLens>();
|
final collection = context.read<CollectionLens>();
|
||||||
final location = await selectLocation(context, entries, collection);
|
final locationByEntry = await selectLocation(context, entries, collection);
|
||||||
if (location == null) return;
|
if (locationByEntry == null) return;
|
||||||
|
|
||||||
await _edit(context, entries, (entry) => entry.editLocation(location));
|
await _edit(context, locationByEntry.keys.toSet(), (entry) => entry.editLocation(locationByEntry[entry]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LatLng?> editLocationByMap(BuildContext context, Set<AvesEntry> entries, LatLng clusterLocation, CollectionLens mapCollection) async {
|
Future<LatLng?> editLocationByMap(BuildContext context, Set<AvesEntry> entries, LatLng clusterLocation, CollectionLens mapCollection) async {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
import 'package:aves/model/entry/extensions/metadata_edition.dart';
|
import 'package:aves/model/entry/extensions/metadata_edition.dart';
|
||||||
import 'package:aves/model/entry/extensions/multipage.dart';
|
import 'package:aves/model/entry/extensions/multipage.dart';
|
||||||
|
import 'package:aves/model/filters/covered/tag.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
import 'package:aves/model/filters/placeholder.dart';
|
import 'package:aves/model/filters/placeholder.dart';
|
||||||
import 'package:aves/model/filters/covered/tag.dart';
|
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/ref/mime_types.dart';
|
import 'package:aves/ref/mime_types.dart';
|
||||||
|
@ -17,9 +17,7 @@ import 'package:aves/widgets/dialogs/entry_editors/edit_rating_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/remove_metadata_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart';
|
import 'package:aves/widgets/dialogs/entry_editors/tag_editor_page.dart';
|
||||||
import 'package:aves_model/aves_model.dart';
|
import 'package:aves_model/aves_model.dart';
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
|
|
||||||
mixin EntryEditorMixin {
|
mixin EntryEditorMixin {
|
||||||
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
Future<DateModifier?> selectDateModifier(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
||||||
|
@ -35,15 +33,13 @@ mixin EntryEditorMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LatLng?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
Future<LocationEditActionResult?> selectLocation(BuildContext context, Set<AvesEntry> entries, CollectionLens? collection) async {
|
||||||
if (entries.isEmpty) return null;
|
if (entries.isEmpty) return null;
|
||||||
|
|
||||||
final entry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
|
return showDialog<LocationEditActionResult>(
|
||||||
|
|
||||||
return showDialog<LatLng>(
|
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => EditEntryLocationDialog(
|
builder: (context) => EditEntryLocationDialog(
|
||||||
entry: entry,
|
entries: entries,
|
||||||
collection: collection,
|
collection: collection,
|
||||||
),
|
),
|
||||||
routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName),
|
routeSettings: const RouteSettings(name: EditEntryLocationDialog.routeName),
|
||||||
|
|
|
@ -3,8 +3,11 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:aves/app_mode.dart';
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/favourites.dart';
|
||||||
|
import 'package:aves/model/entry/extensions/keys.dart';
|
||||||
import 'package:aves/model/entry/extensions/multipage.dart';
|
import 'package:aves/model/entry/extensions/multipage.dart';
|
||||||
import 'package:aves/model/entry/extensions/props.dart';
|
import 'package:aves/model/entry/extensions/props.dart';
|
||||||
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/filters/covered/stored_album.dart';
|
import 'package:aves/model/filters/covered/stored_album.dart';
|
||||||
import 'package:aves/model/filters/trash.dart';
|
import 'package:aves/model/filters/trash.dart';
|
||||||
import 'package:aves/model/highlight.dart';
|
import 'package:aves/model/highlight.dart';
|
||||||
|
@ -37,14 +40,15 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
Future<void> doExport(BuildContext context, Set<AvesEntry> targetEntries, EntryConvertOptions options) async {
|
// returns whether it completed the action (with or without failures)
|
||||||
|
Future<bool> doExport(BuildContext context, Set<AvesEntry> targetEntries, EntryConvertOptions options) async {
|
||||||
final destinationAlbumFilter = await pickAlbum(context: context, moveType: MoveType.export, storedAlbumsOnly: true);
|
final destinationAlbumFilter = await pickAlbum(context: context, moveType: MoveType.export, storedAlbumsOnly: true);
|
||||||
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return;
|
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return false;
|
||||||
|
|
||||||
final destinationAlbum = destinationAlbumFilter.album;
|
final destinationAlbum = destinationAlbumFilter.album;
|
||||||
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
|
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return false;
|
||||||
|
|
||||||
if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return;
|
if (!await checkFreeSpaceForMove(context, targetEntries, destinationAlbum, MoveType.export)) return false;
|
||||||
|
|
||||||
final transientMultiPageInfo = <MultiPageInfo>{};
|
final transientMultiPageInfo = <MultiPageInfo>{};
|
||||||
final selection = <AvesEntry>{};
|
final selection = <AvesEntry>{};
|
||||||
|
@ -89,7 +93,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
),
|
),
|
||||||
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
|
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
|
||||||
);
|
);
|
||||||
if (value == null) return;
|
if (value == null) return false;
|
||||||
nameConflictStrategy = value;
|
nameConflictStrategy = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,13 +110,28 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
),
|
),
|
||||||
itemCount: selectionCount,
|
itemCount: selectionCount,
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final successOps = processed.where((e) => e.success).toSet();
|
final successOps = processed.where((op) => op.success).toSet();
|
||||||
final exportedOps = successOps.where((e) => !e.skipped).toSet();
|
final exportedOps = successOps.where((op) => !op.skipped && op.newFields[EntryFields.uri] != null).toSet();
|
||||||
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).nonNulls.toSet();
|
final newUris = exportedOps.map((op) => op.newFields[EntryFields.uri] as String).toSet();
|
||||||
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||||
|
|
||||||
|
// check source favourite status
|
||||||
|
final favouriteSourceUris = selection.where((entry) => entry.isFavourite).map((entry) => entry.uri).toSet();
|
||||||
|
final favouriteNewUris = <String>{};
|
||||||
|
exportedOps.forEach((op) {
|
||||||
|
final sourceUri = op.uri;
|
||||||
|
if (favouriteSourceUris.contains(sourceUri)) {
|
||||||
|
final newUri = op.newFields[EntryFields.uri] as String;
|
||||||
|
favouriteNewUris.add(newUri);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
source.resumeMonitoring();
|
source.resumeMonitoring();
|
||||||
unawaited(source.refreshUris(newUris));
|
unawaited(source.refreshUris(newUris).then((_) {
|
||||||
|
// transfer favourite status on exports
|
||||||
|
final newFavouriteEntries = source.allEntries.where((entry) => favouriteNewUris.contains(entry.uri)).toSet();
|
||||||
|
favourites.add(newFavouriteEntries);
|
||||||
|
}));
|
||||||
|
|
||||||
// get navigator beforehand because
|
// get navigator beforehand because
|
||||||
// local context may be deactivated when action is triggered after navigation
|
// local context may be deactivated when action is triggered after navigation
|
||||||
|
@ -157,34 +176,44 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
transientMultiPageInfo.forEach((v) => v.dispose());
|
transientMultiPageInfo.forEach((v) => v.dispose());
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> doQuickMove(
|
// returns whether it completed the action (with or without failures)
|
||||||
|
Future<bool> doQuickMove(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required MoveType moveType,
|
required MoveType moveType,
|
||||||
required Map<String, Iterable<AvesEntry>> entriesByDestination,
|
required Map<String, Set<AvesEntry>> entriesByDestination,
|
||||||
bool hideShowAction = false,
|
bool hideShowAction = false,
|
||||||
VoidCallback? onSuccess,
|
VoidCallback? onSuccess,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (moveType == MoveType.move) {
|
||||||
|
// skip moving entries to their directory
|
||||||
|
entriesByDestination.forEach((destinationAlbum, entries) {
|
||||||
|
entries.removeWhere((entry) => entry.directory == destinationAlbum);
|
||||||
|
});
|
||||||
|
entriesByDestination.removeWhere((_, entries) => entries.isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
final entries = entriesByDestination.values.expand((v) => v).toSet();
|
final entries = entriesByDestination.values.expand((v) => v).toSet();
|
||||||
final todoCount = entries.length;
|
final todoCount = entries.length;
|
||||||
assert(todoCount > 0);
|
if (todoCount == 0) return true;
|
||||||
|
|
||||||
final toBin = moveType == MoveType.toBin;
|
final toBin = moveType == MoveType.toBin;
|
||||||
final copy = moveType == MoveType.copy;
|
final copy = moveType == MoveType.copy;
|
||||||
|
|
||||||
// permission for modification at destinations
|
// permission for modification at destinations
|
||||||
final destinationAlbums = entriesByDestination.keys.toSet();
|
final destinationAlbums = entriesByDestination.keys.toSet();
|
||||||
if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return;
|
if (!await checkStoragePermissionForAlbums(context, destinationAlbums)) return false;
|
||||||
|
|
||||||
// permission for modification at origins
|
// permission for modification at origins
|
||||||
final originAlbums = entries.map((e) => e.directory).nonNulls.toSet();
|
final originAlbums = entries.map((e) => e.directory).nonNulls.toSet();
|
||||||
if ({MoveType.move, MoveType.toBin}.contains(moveType) && !await checkStoragePermissionForAlbums(context, originAlbums, entries: entries)) return;
|
if ({MoveType.move, MoveType.toBin}.contains(moveType) && !await checkStoragePermissionForAlbums(context, originAlbums, entries: entries)) return false;
|
||||||
|
|
||||||
final hasEnoughSpaceByDestination = await Future.wait(destinationAlbums.map((destinationAlbum) {
|
final hasEnoughSpaceByDestination = await Future.wait(destinationAlbums.map((destinationAlbum) {
|
||||||
return checkFreeSpaceForMove(context, entries, destinationAlbum, moveType);
|
return checkFreeSpaceForMove(context, entries, destinationAlbum, moveType);
|
||||||
}));
|
}));
|
||||||
if (hasEnoughSpaceByDestination.any((v) => !v)) return;
|
if (hasEnoughSpaceByDestination.any((v) => !v)) return false;
|
||||||
|
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
var nameConflictStrategy = NameConflictStrategy.rename;
|
var nameConflictStrategy = NameConflictStrategy.rename;
|
||||||
|
@ -209,12 +238,12 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
),
|
),
|
||||||
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
|
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
|
||||||
);
|
);
|
||||||
if (value == null) return;
|
if (value == null) return false;
|
||||||
nameConflictStrategy = value;
|
nameConflictStrategy = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ({MoveType.move, MoveType.copy}.contains(moveType) && !await _checkUndatedItems(context, entries)) return;
|
if ({MoveType.move, MoveType.copy}.contains(moveType) && !await _checkUndatedItems(context, entries)) return false;
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
source.pauseMonitoring();
|
source.pauseMonitoring();
|
||||||
|
@ -230,11 +259,11 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onCancel: () => mediaEditService.cancelFileOp(opId),
|
onCancel: () => mediaEditService.cancelFileOp(opId),
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final successOps = processed.where((v) => v.success).toSet();
|
final successOps = processed.where((op) => op.success).toSet();
|
||||||
|
|
||||||
// move
|
// move
|
||||||
final movedOps = successOps.where((v) => !v.skipped && !v.deleted).toSet();
|
final movedOps = successOps.where((op) => !op.skipped && !op.deleted).toSet();
|
||||||
final movedEntries = movedOps.map((v) => v.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).nonNulls.toSet();
|
final movedEntries = movedOps.map((op) => op.uri).map((uri) => entries.firstWhereOrNull((entry) => entry.uri == uri)).nonNulls.toSet();
|
||||||
await source.updateAfterMove(
|
await source.updateAfterMove(
|
||||||
todoEntries: entries,
|
todoEntries: entries,
|
||||||
moveType: moveType,
|
moveType: moveType,
|
||||||
|
@ -243,8 +272,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
);
|
);
|
||||||
|
|
||||||
// delete (when trying to move to bin obsolete entries)
|
// delete (when trying to move to bin obsolete entries)
|
||||||
final deletedOps = successOps.where((v) => v.deleted).toSet();
|
final deletedOps = successOps.where((op) => op.deleted).toSet();
|
||||||
final deletedUris = deletedOps.map((event) => event.uri).toSet();
|
final deletedUris = deletedOps.map((op) => op.uri).toSet();
|
||||||
await source.removeEntries(deletedUris, includeTrash: true);
|
await source.removeEntries(deletedUris, includeTrash: true);
|
||||||
|
|
||||||
source.resumeMonitoring();
|
source.resumeMonitoring();
|
||||||
|
@ -313,9 +342,11 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> doMove(
|
// returns whether it completed the action (with or without failures)
|
||||||
|
Future<bool> doMove(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required MoveType moveType,
|
required MoveType moveType,
|
||||||
required Set<AvesEntry> entries,
|
required Set<AvesEntry> entries,
|
||||||
|
@ -330,7 +361,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
message: l10n.binEntriesConfirmationDialogMessage(entries.length),
|
message: l10n.binEntriesConfirmationDialogMessage(entries.length),
|
||||||
confirmationButtonLabel: l10n.deleteButtonLabel,
|
confirmationButtonLabel: l10n.deleteButtonLabel,
|
||||||
)) {
|
)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,7 +371,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
case MoveType.move:
|
case MoveType.move:
|
||||||
case MoveType.export:
|
case MoveType.export:
|
||||||
final destinationAlbumFilter = await pickAlbum(context: context, moveType: moveType, storedAlbumsOnly: true);
|
final destinationAlbumFilter = await pickAlbum(context: context, moveType: moveType, storedAlbumsOnly: true);
|
||||||
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return;
|
if (destinationAlbumFilter == null || destinationAlbumFilter is! StoredAlbumFilter) return false;
|
||||||
|
|
||||||
final destinationAlbum = destinationAlbumFilter.album;
|
final destinationAlbum = destinationAlbumFilter.album;
|
||||||
settings.recentDestinationAlbums = settings.recentDestinationAlbums
|
settings.recentDestinationAlbums = settings.recentDestinationAlbums
|
||||||
|
@ -357,7 +388,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await doQuickMove(
|
return await doQuickMove(
|
||||||
context,
|
context,
|
||||||
moveType: moveType,
|
moveType: moveType,
|
||||||
entriesByDestination: entriesByDestination,
|
entriesByDestination: entriesByDestination,
|
||||||
|
@ -365,7 +396,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> rename(
|
// returns whether it completed the action (with or without failures)
|
||||||
|
Future<bool> rename(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required Map<AvesEntry, String> entriesToNewName,
|
required Map<AvesEntry, String> entriesToNewName,
|
||||||
required bool persist,
|
required bool persist,
|
||||||
|
@ -375,9 +407,9 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
final todoCount = entries.length;
|
final todoCount = entries.length;
|
||||||
assert(todoCount > 0);
|
assert(todoCount > 0);
|
||||||
|
|
||||||
if (!await checkStoragePermission(context, entries)) return;
|
if (!await checkStoragePermission(context, entries)) return false;
|
||||||
|
|
||||||
if (!await _checkUndatedItems(context, entries)) return;
|
if (!await _checkUndatedItems(context, entries)) return false;
|
||||||
|
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
source.pauseMonitoring();
|
source.pauseMonitoring();
|
||||||
|
@ -391,8 +423,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
itemCount: todoCount,
|
itemCount: todoCount,
|
||||||
onCancel: () => mediaEditService.cancelFileOp(opId),
|
onCancel: () => mediaEditService.cancelFileOp(opId),
|
||||||
onDone: (processed) async {
|
onDone: (processed) async {
|
||||||
final successOps = processed.where((e) => e.success).toSet();
|
final successOps = processed.where((op) => op.success).toSet();
|
||||||
final movedOps = successOps.where((e) => !e.skipped).toSet();
|
final movedOps = successOps.where((op) => !op.skipped).toSet();
|
||||||
await source.updateAfterRename(
|
await source.updateAfterRename(
|
||||||
todoEntries: entries,
|
todoEntries: entries,
|
||||||
movedOps: movedOps,
|
movedOps: movedOps,
|
||||||
|
@ -412,6 +444,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async {
|
Future<bool> _checkUndatedItems(BuildContext context, Set<AvesEntry> entries) async {
|
||||||
|
@ -451,7 +484,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||||
Set<String> destinationAlbums,
|
Set<String> destinationAlbums,
|
||||||
Set<MoveOpEvent> movedOps,
|
Set<MoveOpEvent> movedOps,
|
||||||
) async {
|
) async {
|
||||||
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
|
final newUris = movedOps.map((op) => op.newFields[EntryFields.uri] as String?).toSet();
|
||||||
bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri);
|
bool highlightTest(AvesEntry entry) => newUris.contains(entry.uri);
|
||||||
|
|
||||||
final collection = context.read<CollectionLens?>();
|
final collection = context.read<CollectionLens?>();
|
||||||
|
|
152
lib/widgets/common/basic/time_shift_selector.dart
Normal file
152
lib/widgets/common/basic/time_shift_selector.dart
Normal file
|
@ -0,0 +1,152 @@
|
||||||
|
import 'package:aves/ref/locales.dart';
|
||||||
|
import 'package:aves/utils/time_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/wheel.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class TimeShiftSelector extends StatefulWidget {
|
||||||
|
final TimeShiftController controller;
|
||||||
|
|
||||||
|
const TimeShiftSelector({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TimeShiftSelector> createState() => _TimeShiftSelectorState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimeShiftSelectorState extends State<TimeShiftSelector> {
|
||||||
|
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
|
||||||
|
late ValueNotifier<String> _shiftSign;
|
||||||
|
|
||||||
|
static const _positiveSign = '+';
|
||||||
|
static const _negativeSign = '-';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
var initialValue = widget.controller.initialValue;
|
||||||
|
final sign = initialValue.isNegative ? _negativeSign : _positiveSign;
|
||||||
|
initialValue = initialValue.abs();
|
||||||
|
final hours = initialValue.inHours;
|
||||||
|
initialValue -= Duration(hours: hours);
|
||||||
|
final minutes = initialValue.inMinutes;
|
||||||
|
initialValue -= Duration(minutes: minutes);
|
||||||
|
final seconds = initialValue.inSeconds;
|
||||||
|
|
||||||
|
_shiftSign = ValueNotifier(sign);
|
||||||
|
_shiftHour = ValueNotifier(hours);
|
||||||
|
_shiftMinute = ValueNotifier(minutes);
|
||||||
|
_shiftSecond = ValueNotifier(seconds);
|
||||||
|
|
||||||
|
_shiftSign.addListener(_updateValue);
|
||||||
|
_shiftHour.addListener(_updateValue);
|
||||||
|
_shiftMinute.addListener(_updateValue);
|
||||||
|
_shiftSecond.addListener(_updateValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_shiftSign.dispose();
|
||||||
|
_shiftHour.dispose();
|
||||||
|
_shiftMinute.dispose();
|
||||||
|
_shiftSecond.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
final timeComponentFormatter = NumberFormat('0', context.locale);
|
||||||
|
|
||||||
|
const textStyle = TextStyle(fontSize: 34);
|
||||||
|
const digitsAlign = TextAlign.right;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Table(
|
||||||
|
textDirection: timeComponentsDirection,
|
||||||
|
children: [
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
const SizedBox(),
|
||||||
|
Center(child: Text(l10n.durationDialogHours)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Center(child: Text(l10n.durationDialogMinutes)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Center(child: Text(l10n.durationDialogSeconds)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TableRow(
|
||||||
|
children: [
|
||||||
|
WheelSelector(
|
||||||
|
valueNotifier: _shiftSign,
|
||||||
|
values: const [_positiveSign, _negativeSign],
|
||||||
|
textStyle: textStyle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
format: (v) => v,
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: WheelSelector(
|
||||||
|
valueNotifier: _shiftHour,
|
||||||
|
values: List.generate(hoursInDay, (i) => i),
|
||||||
|
textStyle: textStyle,
|
||||||
|
textAlign: digitsAlign,
|
||||||
|
format: timeComponentFormatter.format,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
':',
|
||||||
|
style: textStyle,
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: WheelSelector(
|
||||||
|
valueNotifier: _shiftMinute,
|
||||||
|
values: List.generate(minutesInHour, (i) => i),
|
||||||
|
textStyle: textStyle,
|
||||||
|
textAlign: digitsAlign,
|
||||||
|
format: timeComponentFormatter.format,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
':',
|
||||||
|
style: textStyle,
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: WheelSelector(
|
||||||
|
valueNotifier: _shiftSecond,
|
||||||
|
values: List.generate(secondsInMinute, (i) => i),
|
||||||
|
textStyle: textStyle,
|
||||||
|
textAlign: digitsAlign,
|
||||||
|
format: timeComponentFormatter.format,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
defaultColumnWidth: const IntrinsicColumnWidth(),
|
||||||
|
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateValue() {
|
||||||
|
final sign = _shiftSign.value == _positiveSign ? 1 : -1;
|
||||||
|
final hours = _shiftHour.value;
|
||||||
|
final minutes = _shiftMinute.value;
|
||||||
|
final seconds = _shiftSecond.value;
|
||||||
|
widget.controller.value = Duration(hours: hours, minutes: minutes, seconds: seconds) * sign;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeShiftController {
|
||||||
|
final Duration initialValue;
|
||||||
|
Duration value;
|
||||||
|
|
||||||
|
TimeShiftController({required this.initialValue}) : value = initialValue;
|
||||||
|
}
|
|
@ -5,10 +5,10 @@ class AvesBorder {
|
||||||
static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26;
|
static Color _borderColor(BuildContext context) => Theme.of(context).isDark ? Colors.white30 : Colors.black26;
|
||||||
|
|
||||||
// 1 device pixel for straight lines is fine
|
// 1 device pixel for straight lines is fine
|
||||||
static double straightBorderWidth(BuildContext context) => 1 / View.of(context).devicePixelRatio;
|
static double straightBorderWidth(BuildContext context) => 1 / MediaQuery.devicePixelRatioOf(context);
|
||||||
|
|
||||||
// 1 device pixel for curves is too thin
|
// 1 device pixel for curves is too thin
|
||||||
static double curvedBorderWidth(BuildContext context) => View.of(context).devicePixelRatio > 2 ? 0.5 : 1.0;
|
static double curvedBorderWidth(BuildContext context) => MediaQuery.devicePixelRatioOf(context) > 2 ? 0.5 : 1.0;
|
||||||
|
|
||||||
static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide(
|
static BorderSide straightSide(BuildContext context, {double? width}) => BorderSide(
|
||||||
color: _borderColor(context),
|
color: _borderColor(context),
|
||||||
|
|
|
@ -42,6 +42,7 @@ class GeoMap extends StatefulWidget {
|
||||||
final ValueNotifier<LatLng?>? dotLocationNotifier;
|
final ValueNotifier<LatLng?>? dotLocationNotifier;
|
||||||
final ValueNotifier<double>? overlayOpacityNotifier;
|
final ValueNotifier<double>? overlayOpacityNotifier;
|
||||||
final MapOverlay? overlayEntry;
|
final MapOverlay? overlayEntry;
|
||||||
|
final Set<List<LatLng>>? tracks;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final MapTapCallback? onMapTap;
|
final MapTapCallback? onMapTap;
|
||||||
final void Function(
|
final void Function(
|
||||||
|
@ -69,6 +70,7 @@ class GeoMap extends StatefulWidget {
|
||||||
this.dotLocationNotifier,
|
this.dotLocationNotifier,
|
||||||
this.overlayOpacityNotifier,
|
this.overlayOpacityNotifier,
|
||||||
this.overlayEntry,
|
this.overlayEntry,
|
||||||
|
this.tracks,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
@ -135,7 +137,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final devicePixelRatio = View.of(context).devicePixelRatio;
|
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||||
void onMarkerLongPress(GeoEntry<AvesEntry> geoEntry, LatLng tapLocation) => _onMarkerLongPress(
|
void onMarkerLongPress(GeoEntry<AvesEntry> geoEntry, LatLng tapLocation) => _onMarkerLongPress(
|
||||||
geoEntry: geoEntry,
|
geoEntry: geoEntry,
|
||||||
tapLocation: tapLocation,
|
tapLocation: tapLocation,
|
||||||
|
@ -179,6 +181,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
dotLocationNotifier: widget.dotLocationNotifier,
|
dotLocationNotifier: widget.dotLocationNotifier,
|
||||||
overlayOpacityNotifier: widget.overlayOpacityNotifier,
|
overlayOpacityNotifier: widget.overlayOpacityNotifier,
|
||||||
overlayEntry: widget.overlayEntry,
|
overlayEntry: widget.overlayEntry,
|
||||||
|
tracks: widget.tracks,
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
onMapTap: widget.onMapTap,
|
onMapTap: widget.onMapTap,
|
||||||
onMarkerTap: _onMarkerTap,
|
onMarkerTap: _onMarkerTap,
|
||||||
|
@ -210,6 +213,7 @@ class _GeoMapState extends State<GeoMap> {
|
||||||
),
|
),
|
||||||
overlayOpacityNotifier: widget.overlayOpacityNotifier,
|
overlayOpacityNotifier: widget.overlayOpacityNotifier,
|
||||||
overlayEntry: widget.overlayEntry,
|
overlayEntry: widget.overlayEntry,
|
||||||
|
tracks: widget.tracks,
|
||||||
onUserZoomChange: widget.onUserZoomChange,
|
onUserZoomChange: widget.onUserZoomChange,
|
||||||
onMapTap: widget.onMapTap,
|
onMapTap: widget.onMapTap,
|
||||||
onMarkerTap: _onMarkerTap,
|
onMarkerTap: _onMarkerTap,
|
||||||
|
|
|
@ -30,6 +30,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
||||||
final Size markerSize, dotMarkerSize;
|
final Size markerSize, dotMarkerSize;
|
||||||
final ValueNotifier<double>? overlayOpacityNotifier;
|
final ValueNotifier<double>? overlayOpacityNotifier;
|
||||||
final MapOverlay? overlayEntry;
|
final MapOverlay? overlayEntry;
|
||||||
|
final Set<List<LatLng>>? tracks;
|
||||||
final UserZoomChangeCallback? onUserZoomChange;
|
final UserZoomChangeCallback? onUserZoomChange;
|
||||||
final MapTapCallback? onMapTap;
|
final MapTapCallback? onMapTap;
|
||||||
final MarkerTapCallback<T>? onMarkerTap;
|
final MarkerTapCallback<T>? onMarkerTap;
|
||||||
|
@ -52,6 +53,7 @@ class EntryLeafletMap<T> extends StatefulWidget {
|
||||||
required this.dotMarkerSize,
|
required this.dotMarkerSize,
|
||||||
this.overlayOpacityNotifier,
|
this.overlayOpacityNotifier,
|
||||||
this.overlayEntry,
|
this.overlayEntry,
|
||||||
|
this.tracks,
|
||||||
this.onUserZoomChange,
|
this.onUserZoomChange,
|
||||||
this.onMapTap,
|
this.onMapTap,
|
||||||
this.onMarkerTap,
|
this.onMarkerTap,
|
||||||
|
@ -175,6 +177,7 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
|
||||||
children: [
|
children: [
|
||||||
_buildMapLayer(),
|
_buildMapLayer(),
|
||||||
if (widget.overlayEntry != null) _buildOverlayImageLayer(),
|
if (widget.overlayEntry != null) _buildOverlayImageLayer(),
|
||||||
|
if (widget.tracks != null) _buildTracksLayer(),
|
||||||
MarkerLayer(
|
MarkerLayer(
|
||||||
markers: markers,
|
markers: markers,
|
||||||
rotate: true,
|
rotate: true,
|
||||||
|
@ -243,6 +246,22 @@ class _EntryLeafletMapState<T> extends State<EntryLeafletMap<T>> with TickerProv
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildTracksLayer() {
|
||||||
|
final tracks = widget.tracks;
|
||||||
|
if (tracks == null) return const SizedBox();
|
||||||
|
|
||||||
|
final trackColor = Theme.of(context).colorScheme.primary;
|
||||||
|
return PolylineLayer(
|
||||||
|
polylines: tracks
|
||||||
|
.map((v) => Polyline(
|
||||||
|
points: v,
|
||||||
|
strokeWidth: MapThemeData.trackWidth.toDouble(),
|
||||||
|
color: trackColor,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onBoundsChanged() => _debouncer(_onIdle);
|
void _onBoundsChanged() => _debouncer(_onIdle);
|
||||||
|
|
||||||
void _onIdle() {
|
void _onIdle() {
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/analysis_service.dart';
|
import 'package:aves/services/analysis_service.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
import 'package:aves/widgets/debug/overlay.dart';
|
import 'package:aves/widgets/debug/overlay.dart';
|
||||||
|
import 'package:aves/widgets/settings/common/tiles.dart';
|
||||||
import 'package:aves/widgets/viewer/info/common.dart';
|
import 'package:aves/widgets/viewer/info/common.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -61,6 +63,11 @@ class _DebugGeneralSectionState extends State<DebugGeneralSection> with Automati
|
||||||
},
|
},
|
||||||
title: const Text('Show tasks overlay'),
|
title: const Text('Show tasks overlay'),
|
||||||
),
|
),
|
||||||
|
SettingsSwitchListTile(
|
||||||
|
selector: (context, s) => s.debugShowViewerTiles,
|
||||||
|
onChanged: (v) => settings.debugShowViewerTiles = v,
|
||||||
|
title: 'Show viewer tiles',
|
||||||
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => LeakTracking.collectLeaks().then((leaks) {
|
onPressed: () => LeakTracking.collectLeaks().then((leaks) {
|
||||||
const config = LeakDiagnosticConfig(
|
const config = LeakDiagnosticConfig(
|
||||||
|
|
|
@ -1,24 +1,21 @@
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
import 'package:aves/model/metadata/date_modifier.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/ref/locales.dart';
|
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/theme/format.dart';
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/theme/themes.dart';
|
import 'package:aves/theme/themes.dart';
|
||||||
import 'package:aves/utils/time_utils.dart';
|
|
||||||
import 'package:aves/view/view.dart';
|
import 'package:aves/view/view.dart';
|
||||||
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
|
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
|
||||||
import 'package:aves/widgets/common/basic/wheel.dart';
|
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/fx/transitions.dart';
|
import 'package:aves/widgets/common/fx/transitions.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
|
import 'package:aves/widgets/common/basic/time_shift_selector.dart';
|
||||||
import 'package:aves/widgets/dialogs/item_picker.dart';
|
import 'package:aves/widgets/dialogs/item_picker.dart';
|
||||||
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
|
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
|
||||||
import 'package:aves_model/aves_model.dart';
|
import 'package:aves_model/aves_model.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class EditEntryDateDialog extends StatefulWidget {
|
class EditEntryDateDialog extends StatefulWidget {
|
||||||
|
@ -42,17 +39,13 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
|
DateFieldSource _copyFieldSource = DateFieldSource.fileModifiedDate;
|
||||||
late AvesEntry _copyItemSource;
|
late AvesEntry _copyItemSource;
|
||||||
late DateTime _customDateTime;
|
late DateTime _customDateTime;
|
||||||
late ValueNotifier<int> _shiftHour, _shiftMinute, _shiftSecond;
|
late TimeShiftController _timeShiftController;
|
||||||
late ValueNotifier<String> _shiftSign;
|
|
||||||
bool _showOptions = false;
|
bool _showOptions = false;
|
||||||
final Set<MetadataField> _fields = {...DateModifier.writableFields};
|
final Set<MetadataField> _fields = {...DateModifier.writableFields};
|
||||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
|
DateTime get copyItemDate => _copyItemSource.bestDate ?? DateTime.now();
|
||||||
|
|
||||||
static const _positiveSign = '+';
|
|
||||||
static const _negativeSign = '-';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -65,10 +58,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_isValidNotifier.dispose();
|
_isValidNotifier.dispose();
|
||||||
_shiftHour.dispose();
|
|
||||||
_shiftMinute.dispose();
|
|
||||||
_shiftSecond.dispose();
|
|
||||||
_shiftSign.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,10 +70,9 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initShift() {
|
void _initShift() {
|
||||||
_shiftHour = ValueNotifier(1);
|
_timeShiftController = TimeShiftController(
|
||||||
_shiftMinute = ValueNotifier(0);
|
initialValue: const Duration(hours: 1),
|
||||||
_shiftSecond = ValueNotifier(0);
|
);
|
||||||
_shiftSign = ValueNotifier(_positiveSign);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -203,80 +191,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildShiftContent(BuildContext context) {
|
Widget _buildShiftContent(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
return TimeShiftSelector(controller: _timeShiftController);
|
||||||
final timeComponentFormatter = NumberFormat('0', context.locale);
|
|
||||||
|
|
||||||
const textStyle = TextStyle(fontSize: 34);
|
|
||||||
const digitsAlign = TextAlign.right;
|
|
||||||
|
|
||||||
return Center(
|
|
||||||
child: Table(
|
|
||||||
textDirection: timeComponentsDirection,
|
|
||||||
children: [
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
const SizedBox(),
|
|
||||||
Center(child: Text(l10n.durationDialogHours)),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Center(child: Text(l10n.durationDialogMinutes)),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Center(child: Text(l10n.durationDialogSeconds)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
TableRow(
|
|
||||||
children: [
|
|
||||||
WheelSelector(
|
|
||||||
valueNotifier: _shiftSign,
|
|
||||||
values: const [_positiveSign, _negativeSign],
|
|
||||||
textStyle: textStyle,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
format: (v) => v,
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: WheelSelector(
|
|
||||||
valueNotifier: _shiftHour,
|
|
||||||
values: List.generate(hoursInDay, (i) => i),
|
|
||||||
textStyle: textStyle,
|
|
||||||
textAlign: digitsAlign,
|
|
||||||
format: timeComponentFormatter.format,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Text(
|
|
||||||
':',
|
|
||||||
style: textStyle,
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: WheelSelector(
|
|
||||||
valueNotifier: _shiftMinute,
|
|
||||||
values: List.generate(minutesInHour, (i) => i),
|
|
||||||
textStyle: textStyle,
|
|
||||||
textAlign: digitsAlign,
|
|
||||||
format: timeComponentFormatter.format,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Text(
|
|
||||||
':',
|
|
||||||
style: textStyle,
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: WheelSelector(
|
|
||||||
valueNotifier: _shiftSecond,
|
|
||||||
values: List.generate(secondsInMinute, (i) => i),
|
|
||||||
textStyle: textStyle,
|
|
||||||
textAlign: digitsAlign,
|
|
||||||
format: timeComponentFormatter.format,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
],
|
|
||||||
defaultColumnWidth: const IntrinsicColumnWidth(),
|
|
||||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDestinationFields(BuildContext context) {
|
Widget _buildDestinationFields(BuildContext context) {
|
||||||
|
@ -368,7 +283,6 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
pickCollection.dispose();
|
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
setState(() => _copyItemSource = entry);
|
setState(() => _copyItemSource = entry);
|
||||||
}
|
}
|
||||||
|
@ -388,8 +302,7 @@ class _EditEntryDateDialogState extends State<EditEntryDateDialog> {
|
||||||
case DateEditAction.extractFromTitle:
|
case DateEditAction.extractFromTitle:
|
||||||
return DateModifier.extractFromTitle();
|
return DateModifier.extractFromTitle();
|
||||||
case DateEditAction.shift:
|
case DateEditAction.shift:
|
||||||
final shiftTotalSeconds = ((_shiftHour.value * minutesInHour + _shiftMinute.value) * secondsInMinute + _shiftSecond.value) * (_shiftSign.value == _positiveSign ? 1 : -1);
|
return DateModifier.shift(_fields, _timeShiftController.value.inSeconds);
|
||||||
return DateModifier.shift(_fields, shiftTotalSeconds);
|
|
||||||
case DateEditAction.remove:
|
case DateEditAction.remove:
|
||||||
return DateModifier.remove(_fields);
|
return DateModifier.remove(_fields);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,40 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:aves/app_mode.dart';
|
||||||
import 'package:aves/model/entry/entry.dart';
|
import 'package:aves/model/entry/entry.dart';
|
||||||
import 'package:aves/model/entry/extensions/location.dart';
|
import 'package:aves/model/entry/extensions/location.dart';
|
||||||
import 'package:aves/model/entry/extensions/metadata_edition.dart';
|
import 'package:aves/model/entry/extensions/metadata_edition.dart';
|
||||||
|
import 'package:aves/model/entry/sort.dart';
|
||||||
import 'package:aves/model/filters/covered/location.dart';
|
import 'package:aves/model/filters/covered/location.dart';
|
||||||
|
import 'package:aves/model/metadata/catalog.dart';
|
||||||
import 'package:aves/model/settings/enums/coordinate_format.dart';
|
import 'package:aves/model/settings/enums/coordinate_format.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_lens.dart';
|
import 'package:aves/model/source/collection_lens.dart';
|
||||||
import 'package:aves/ref/poi.dart';
|
import 'package:aves/ref/poi.dart';
|
||||||
|
import 'package:aves/services/common/services.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
|
import 'package:aves/theme/format.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/theme/themes.dart';
|
import 'package:aves/theme/themes.dart';
|
||||||
import 'package:aves/view/view.dart';
|
import 'package:aves/view/view.dart';
|
||||||
import 'package:aves/widgets/aves_app.dart';
|
import 'package:aves/widgets/aves_app.dart';
|
||||||
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
|
import 'package:aves/widgets/common/basic/text_dropdown_button.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/fx/transitions.dart';
|
import 'package:aves/widgets/common/fx/transitions.dart';
|
||||||
|
import 'package:aves/widgets/common/identity/aves_caption.dart';
|
||||||
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
import 'package:aves/widgets/dialogs/item_picker.dart';
|
import 'package:aves/widgets/dialogs/item_picker.dart';
|
||||||
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
|
import 'package:aves/widgets/dialogs/pick_dialogs/item_pick_page.dart';
|
||||||
import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart';
|
import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart';
|
||||||
|
import 'package:aves/widgets/dialogs/time_shift_dialog.dart';
|
||||||
|
import 'package:aves/widgets/map/map_page.dart';
|
||||||
import 'package:aves_model/aves_model.dart';
|
import 'package:aves_model/aves_model.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gpx/gpx.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
@ -31,12 +42,12 @@ import 'package:provider/provider.dart';
|
||||||
class EditEntryLocationDialog extends StatefulWidget {
|
class EditEntryLocationDialog extends StatefulWidget {
|
||||||
static const routeName = '/dialog/edit_entry_location';
|
static const routeName = '/dialog/edit_entry_location';
|
||||||
|
|
||||||
final AvesEntry entry;
|
final Set<AvesEntry> entries;
|
||||||
final CollectionLens? collection;
|
final CollectionLens? collection;
|
||||||
|
|
||||||
const EditEntryLocationDialog({
|
const EditEntryLocationDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.entry,
|
required this.entries,
|
||||||
this.collection,
|
this.collection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -44,19 +55,26 @@ class EditEntryLocationDialog extends StatefulWidget {
|
||||||
State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState();
|
State<EditEntryLocationDialog> createState() => _EditEntryLocationDialogState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> with FeedbackMixin {
|
||||||
final List<StreamSubscription> _subscriptions = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
LocationEditAction _action = LocationEditAction.chooseOnMap;
|
LocationEditAction _action = LocationEditAction.chooseOnMap;
|
||||||
LatLng? _mapCoordinates;
|
LatLng? _mapCoordinates;
|
||||||
|
late final AvesEntry mainEntry;
|
||||||
late AvesEntry _copyItemSource;
|
late AvesEntry _copyItemSource;
|
||||||
|
Gpx? _gpx;
|
||||||
|
Duration _gpxShift = Duration.zero;
|
||||||
|
final Map<AvesEntry, LatLng> _gpxMap = {};
|
||||||
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
|
final TextEditingController _latitudeController = TextEditingController(), _longitudeController = TextEditingController();
|
||||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||||
|
|
||||||
NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale);
|
NumberFormat get coordinateFormatter => NumberFormat('0.000000', context.locale);
|
||||||
|
static const _minTimeToGpxPoint = Duration(hours: 1);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
final entries = widget.entries;
|
||||||
|
mainEntry = entries.firstWhereOrNull((entry) => entry.hasGps) ?? entries.first;
|
||||||
_initMapCoordinates();
|
_initMapCoordinates();
|
||||||
_initCopyItem();
|
_initCopyItem();
|
||||||
_initCustom();
|
_initCustom();
|
||||||
|
@ -64,16 +82,16 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initMapCoordinates() {
|
void _initMapCoordinates() {
|
||||||
_mapCoordinates = widget.entry.latLng;
|
_mapCoordinates = mainEntry.latLng;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initCopyItem() {
|
void _initCopyItem() {
|
||||||
_copyItemSource = widget.entry;
|
_copyItemSource = mainEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initCustom() {
|
void _initCustom() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
final latLng = widget.entry.latLng;
|
final latLng = mainEntry.latLng;
|
||||||
if (latLng != null) {
|
if (latLng != null) {
|
||||||
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
|
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
|
||||||
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
|
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
|
||||||
|
@ -128,14 +146,9 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
switchInCurve: Curves.easeInOutCubic,
|
switchInCurve: Curves.easeInOutCubic,
|
||||||
switchOutCurve: Curves.easeInOutCubic,
|
switchOutCurve: Curves.easeInOutCubic,
|
||||||
transitionBuilder: AvesTransitions.formTransitionBuilder,
|
transitionBuilder: AvesTransitions.formTransitionBuilder,
|
||||||
child: Column(
|
child: KeyedSubtree(
|
||||||
key: ValueKey(_action),
|
key: ValueKey(_action),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: _buildContent(),
|
||||||
children: [
|
|
||||||
if (_action == LocationEditAction.chooseOnMap) _buildChooseOnMapContent(context),
|
|
||||||
if (_action == LocationEditAction.copyItem) _buildCopyItemContent(context),
|
|
||||||
if (_action == LocationEditAction.setCustom) _buildSetCustomContent(context),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
@ -158,12 +171,27 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
switch (_action) {
|
||||||
|
case LocationEditAction.chooseOnMap:
|
||||||
|
return _buildChooseOnMapContent(context);
|
||||||
|
case LocationEditAction.copyItem:
|
||||||
|
return _buildCopyItemContent(context);
|
||||||
|
case LocationEditAction.setCustom:
|
||||||
|
return _buildSetCustomContent(context);
|
||||||
|
case LocationEditAction.importGpx:
|
||||||
|
return _buildImportGpxContent(context);
|
||||||
|
case LocationEditAction.remove:
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildChooseOnMapContent(BuildContext context) {
|
Widget _buildChooseOnMapContent(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _toText(context, _mapCoordinates)),
|
Expanded(child: _coordinatesText(context, _mapCoordinates)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(AIcons.map),
|
icon: const Icon(AIcons.map),
|
||||||
|
@ -179,8 +207,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
|
_latitudeController.text = coordinateFormatter.format(latLng.latitude);
|
||||||
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
|
_longitudeController.text = coordinateFormatter.format(latLng.longitude);
|
||||||
_action = LocationEditAction.setCustom;
|
_action = LocationEditAction.setCustom;
|
||||||
_validate();
|
setState(_validate);
|
||||||
setState(() {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectionLens? _createPickCollection() {
|
CollectionLens? _createPickCollection() {
|
||||||
|
@ -208,7 +235,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
pickCollection?.dispose();
|
|
||||||
if (latLng != null) {
|
if (latLng != null) {
|
||||||
settings.mapDefaultCenter = latLng;
|
settings.mapDefaultCenter = latLng;
|
||||||
setState(() {
|
setState(() {
|
||||||
|
@ -223,7 +249,7 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _toText(context, _copyItemSource.latLng)),
|
Expanded(child: _coordinatesText(context, _copyItemSource.latLng)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ItemPicker(
|
ItemPicker(
|
||||||
extent: 48,
|
extent: 48,
|
||||||
|
@ -249,7 +275,6 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
fullscreenDialog: true,
|
fullscreenDialog: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
pickCollection.dispose();
|
|
||||||
if (entry != null) {
|
if (entry != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_copyItemSource = entry;
|
_copyItemSource = entry;
|
||||||
|
@ -293,13 +318,207 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Text _toText(BuildContext context, LatLng? latLng) {
|
Widget _buildImportGpxContent(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
if (latLng != null) {
|
return Padding(
|
||||||
return Text(
|
padding: const EdgeInsetsDirectional.only(start: 16, end: 8),
|
||||||
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _gpxDateRangeText(context, _gpx)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(AIcons.fileImport),
|
||||||
|
onPressed: _pickGpx,
|
||||||
|
tooltip: l10n.pickTooltip,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_gpx != null) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(l10n.editEntryLocationDialogTimeShift),
|
||||||
|
AvesCaption(_formatShiftDuration(_gpxShift)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(AIcons.edit),
|
||||||
|
onPressed: _pickGpxShift,
|
||||||
|
tooltip: l10n.changeTooltip,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(l10n.statsWithGps(_gpxMap.length))),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(AIcons.map),
|
||||||
|
onPressed: _previewGpx,
|
||||||
|
tooltip: l10n.openMapPageTooltip,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickGpx() async {
|
||||||
|
final bytes = await storageService.openFile();
|
||||||
|
if (bytes.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
final allXmlString = utf8.decode(bytes);
|
||||||
|
final gpx = GpxReader().fromString(allXmlString);
|
||||||
|
|
||||||
|
_gpx = gpx;
|
||||||
|
_gpxShift = Duration.zero;
|
||||||
|
_updateGpxMapping();
|
||||||
|
|
||||||
|
showFeedback(context, FeedbackType.info, context.l10n.genericSuccessFeedback);
|
||||||
|
} catch (error, stack) {
|
||||||
|
debugPrint('failed to import GPX, error=$error\n$stack');
|
||||||
|
showFeedback(context, FeedbackType.warn, context.l10n.genericFailureFeedback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickGpxShift() async {
|
||||||
|
final newShift = await showDialog<Duration>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => TimeShiftDialog(
|
||||||
|
initialValue: _gpxShift,
|
||||||
|
),
|
||||||
|
routeSettings: const RouteSettings(name: TimeShiftDialog.routeName),
|
||||||
|
);
|
||||||
|
if (newShift == null) return;
|
||||||
|
|
||||||
|
_gpxShift = newShift;
|
||||||
|
_updateGpxMapping();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatShiftDuration(Duration duration) {
|
||||||
|
final sign = duration.isNegative ? '-' : '+';
|
||||||
|
duration = duration.abs();
|
||||||
|
final hours = duration.inHours;
|
||||||
|
duration -= Duration(hours: hours);
|
||||||
|
final minutes = duration.inMinutes;
|
||||||
|
duration -= Duration(minutes: minutes);
|
||||||
|
final seconds = duration.inSeconds;
|
||||||
|
return '$sign$hours:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateGpxMapping() {
|
||||||
|
_gpxMap.clear();
|
||||||
|
|
||||||
|
final gpx = _gpx;
|
||||||
|
if (gpx == null) return;
|
||||||
|
|
||||||
|
final Map<AvesEntry, Wpt> wptByEntry = {};
|
||||||
|
|
||||||
|
// dated items and points, oldest first
|
||||||
|
final sortedEntries = widget.entries.where((v) => v.bestDate != null).sorted(AvesEntrySort.compareByDate).reversed.toList();
|
||||||
|
final sortedPoints = gpx.trks.expand((trk) => trk.trksegs).expand((trkSeg) => trkSeg.trkpts).where((v) => v.time != null).sortedBy((v) => v.time!);
|
||||||
|
if (sortedEntries.isNotEmpty && sortedPoints.isNotEmpty) {
|
||||||
|
int entryIndex = 0;
|
||||||
|
int pointIndex = 0;
|
||||||
|
final int maxDurationSecs = const Duration(days: 365).inSeconds;
|
||||||
|
int smallestDifferenceSecs = maxDurationSecs;
|
||||||
|
while (entryIndex < sortedEntries.length && pointIndex < sortedPoints.length) {
|
||||||
|
final entry = sortedEntries[entryIndex];
|
||||||
|
final point = sortedPoints[pointIndex];
|
||||||
|
final entryDate = entry.bestDate!;
|
||||||
|
final pointTime = point.time!.add(_gpxShift);
|
||||||
|
final differenceSecs = entryDate.difference(pointTime).inSeconds.abs();
|
||||||
|
if (differenceSecs < smallestDifferenceSecs) {
|
||||||
|
smallestDifferenceSecs = differenceSecs;
|
||||||
|
wptByEntry[entry] = point;
|
||||||
|
pointIndex++;
|
||||||
} else {
|
} else {
|
||||||
|
smallestDifferenceSecs = maxDurationSecs;
|
||||||
|
entryIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_gpxMap.addEntries(wptByEntry.entries.map((kv) {
|
||||||
|
final entry = kv.key;
|
||||||
|
final wpt = kv.value;
|
||||||
|
final timeToPoint = entry.bestDate!.difference(wpt.time!.add(_gpxShift)).abs();
|
||||||
|
if (timeToPoint < _minTimeToGpxPoint) {
|
||||||
|
final lat = wpt.lat;
|
||||||
|
final lon = wpt.lon;
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
return MapEntry(entry, LatLng(lat, lon));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).nonNulls);
|
||||||
|
|
||||||
|
setState(_validate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _previewGpx() async {
|
||||||
|
final source = widget.collection?.source;
|
||||||
|
if (source == null) return;
|
||||||
|
|
||||||
|
final previewEntries = _gpxMap.entries.map((kv) {
|
||||||
|
final entry = kv.key.copyWith();
|
||||||
|
final latLng = kv.value;
|
||||||
|
final catalogMetadata = entry.catalogMetadata?.copyWith() ?? CatalogMetadata(id: entry.id);
|
||||||
|
catalogMetadata.latitude = latLng.latitude;
|
||||||
|
catalogMetadata.longitude = latLng.longitude;
|
||||||
|
entry.catalogMetadata = catalogMetadata;
|
||||||
|
return entry;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final mapCollection = CollectionLens(
|
||||||
|
source: source,
|
||||||
|
listenToSource: false,
|
||||||
|
fixedSelection: previewEntries,
|
||||||
|
);
|
||||||
|
|
||||||
|
final tracks = _gpx?.trks
|
||||||
|
.expand((trk) => trk.trksegs)
|
||||||
|
.map((trkSeg) => trkSeg.trkpts
|
||||||
|
.map((wpt) {
|
||||||
|
final lat = wpt.lat;
|
||||||
|
final lon = wpt.lon;
|
||||||
|
return (lat != null && lon != null) ? LatLng(lat, lon) : null;
|
||||||
|
})
|
||||||
|
.nonNulls
|
||||||
|
.toList())
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
await Navigator.maybeOf(context)?.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: LocationPickPage.routeName),
|
||||||
|
builder: (context) {
|
||||||
|
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||||
|
value: ValueNotifier(AppMode.previewMap),
|
||||||
|
child: MapPage(
|
||||||
|
collection: mapCollection,
|
||||||
|
tracks: tracks,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Text _unknownText(BuildContext context) {
|
||||||
|
final l10n = context.l10n;
|
||||||
return Text(
|
return Text(
|
||||||
l10n.viewerInfoUnknown,
|
l10n.viewerInfoUnknown,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
@ -307,6 +526,39 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(DateTime, DateTime)? _gpxDateRange(Gpx? gpx) {
|
||||||
|
final firstDate = gpx?.trks.firstOrNull?.trksegs.firstOrNull?.trkpts.firstOrNull?.time;
|
||||||
|
final lastDate = gpx?.trks.lastOrNull?.trksegs.lastOrNull?.trkpts.lastOrNull?.time;
|
||||||
|
return firstDate != null && lastDate != null ? (firstDate, lastDate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text _gpxDateRangeText(BuildContext context, Gpx? gpx) {
|
||||||
|
final dateRange = _gpxDateRange(gpx);
|
||||||
|
if (dateRange != null) {
|
||||||
|
final (firstDate, lastDate) = dateRange;
|
||||||
|
final locale = context.locale;
|
||||||
|
final use24hour = MediaQuery.alwaysUse24HourFormatOf(context);
|
||||||
|
return Text(
|
||||||
|
[
|
||||||
|
formatDateTime(firstDate.toLocal(), locale, use24hour),
|
||||||
|
formatDateTime(lastDate.toLocal(), locale, use24hour),
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return _unknownText(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text _coordinatesText(BuildContext context, LatLng? latLng) {
|
||||||
|
final l10n = context.l10n;
|
||||||
|
if (latLng != null) {
|
||||||
|
return Text(
|
||||||
|
ExtraCoordinateFormat.toDMS(l10n, latLng).join('\n'),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return _unknownText(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LatLng? _parseLatLng() {
|
LatLng? _parseLatLng() {
|
||||||
|
@ -334,6 +586,8 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
_isValidNotifier.value = _copyItemSource.hasGps;
|
_isValidNotifier.value = _copyItemSource.hasGps;
|
||||||
case LocationEditAction.setCustom:
|
case LocationEditAction.setCustom:
|
||||||
_isValidNotifier.value = _parseLatLng() != null;
|
_isValidNotifier.value = _parseLatLng() != null;
|
||||||
|
case LocationEditAction.importGpx:
|
||||||
|
_isValidNotifier.value = _gpxMap.isNotEmpty;
|
||||||
case LocationEditAction.remove:
|
case LocationEditAction.remove:
|
||||||
_isValidNotifier.value = true;
|
_isValidNotifier.value = true;
|
||||||
}
|
}
|
||||||
|
@ -341,15 +595,23 @@ class _EditEntryLocationDialogState extends State<EditEntryLocationDialog> {
|
||||||
|
|
||||||
void _submit(BuildContext context) {
|
void _submit(BuildContext context) {
|
||||||
final navigator = Navigator.maybeOf(context);
|
final navigator = Navigator.maybeOf(context);
|
||||||
|
final entries = widget.entries;
|
||||||
|
final LocationEditActionResult result = {};
|
||||||
|
void addLocationForAllEntries(LatLng? latLng) => result.addEntries(entries.map((v) => MapEntry(v, latLng)));
|
||||||
switch (_action) {
|
switch (_action) {
|
||||||
case LocationEditAction.chooseOnMap:
|
case LocationEditAction.chooseOnMap:
|
||||||
navigator?.pop(_mapCoordinates);
|
addLocationForAllEntries(_mapCoordinates);
|
||||||
case LocationEditAction.copyItem:
|
case LocationEditAction.copyItem:
|
||||||
navigator?.pop(_copyItemSource.latLng);
|
addLocationForAllEntries(_copyItemSource.latLng);
|
||||||
case LocationEditAction.setCustom:
|
case LocationEditAction.setCustom:
|
||||||
navigator?.pop(_parseLatLng());
|
addLocationForAllEntries(_parseLatLng());
|
||||||
|
case LocationEditAction.importGpx:
|
||||||
|
result.addAll(_gpxMap);
|
||||||
case LocationEditAction.remove:
|
case LocationEditAction.remove:
|
||||||
navigator?.pop(ExtraAvesEntryMetadataEdition.removalLocation);
|
addLocationForAllEntries(ExtraAvesEntryMetadataEdition.removalLocation);
|
||||||
}
|
}
|
||||||
|
navigator?.pop(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typedef LocationEditActionResult = Map<AvesEntry, LatLng?>;
|
||||||
|
|
|
@ -29,7 +29,7 @@ class RemoveEntryMetadataDialog extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
||||||
late final List<MetadataType> _mainOptions, _moreOptions;
|
late final List<MetadataType> _allOptions, _mainOptions, _moreOptions;
|
||||||
final Set<MetadataType> _types = {};
|
final Set<MetadataType> _types = {};
|
||||||
bool _showMore = false;
|
bool _showMore = false;
|
||||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||||
|
@ -37,10 +37,11 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final byMain = groupBy([
|
_allOptions = [
|
||||||
...MetadataTypes.common,
|
...MetadataTypes.common,
|
||||||
if (widget.showJpegTypes) ...MetadataTypes.jpeg,
|
if (widget.showJpegTypes) ...MetadataTypes.jpeg,
|
||||||
], MetadataTypes.main.contains);
|
];
|
||||||
|
final byMain = groupBy(_allOptions, MetadataTypes.main.contains);
|
||||||
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
|
_mainOptions = (byMain[true] ?? [])..sort(_compareTypeText);
|
||||||
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);
|
_moreOptions = (byMain[false] ?? [])..sort(_compareTypeText);
|
||||||
_validate();
|
_validate();
|
||||||
|
@ -59,6 +60,17 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
||||||
return AvesDialog(
|
return AvesDialog(
|
||||||
title: l10n.removeEntryMetadataDialogTitle,
|
title: l10n.removeEntryMetadataDialogTitle,
|
||||||
scrollableContent: [
|
scrollableContent: [
|
||||||
|
SwitchListTile(
|
||||||
|
value: _types.length == _allOptions.length,
|
||||||
|
onChanged: (selected) {
|
||||||
|
selected ? _types.addAll(_allOptions) : _types.clear();
|
||||||
|
setState(_validate);
|
||||||
|
},
|
||||||
|
title: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(l10n.removeEntryMetadataDialogAll),
|
||||||
|
),
|
||||||
|
),
|
||||||
..._mainOptions.map(_toTile),
|
..._mainOptions.map(_toTile),
|
||||||
if (_moreOptions.isNotEmpty)
|
if (_moreOptions.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
|
@ -131,8 +143,7 @@ class _RemoveEntryMetadataDialogState extends State<RemoveEntryMetadataDialog> {
|
||||||
value: _types.contains(type),
|
value: _types.contains(type),
|
||||||
onChanged: (selected) {
|
onChanged: (selected) {
|
||||||
selected ? _types.add(type) : _types.remove(type);
|
selected ? _types.add(type) : _types.remove(type);
|
||||||
_validate();
|
setState(_validate);
|
||||||
setState(() {});
|
|
||||||
},
|
},
|
||||||
title: Align(
|
title: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
|
|
|
@ -33,17 +33,18 @@ class ItemPickPage extends StatefulWidget {
|
||||||
class _ItemPickPageState extends State<ItemPickPage> {
|
class _ItemPickPageState extends State<ItemPickPage> {
|
||||||
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization);
|
final ValueNotifier<AppMode> _appModeNotifier = ValueNotifier(AppMode.initialization);
|
||||||
|
|
||||||
CollectionLens get collection => widget.collection;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
collection.dispose();
|
|
||||||
_appModeNotifier.dispose();
|
_appModeNotifier.dispose();
|
||||||
|
// provided collection should be a new instance specifically created
|
||||||
|
// for the `ItemPickPage` widget, so it can be safely disposed here
|
||||||
|
widget.collection.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final collection = widget.collection;
|
||||||
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
final liveFilter = collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?;
|
||||||
_appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal;
|
_appModeNotifier.value = widget.canRemoveFilters ? AppMode.pickUnfilteredMediaInternal : AppMode.pickFilteredMediaInternal;
|
||||||
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||||
|
|
|
@ -99,6 +99,9 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
|
||||||
_isPageAnimatingNotifier.dispose();
|
_isPageAnimatingNotifier.dispose();
|
||||||
_dotLocationNotifier.dispose();
|
_dotLocationNotifier.dispose();
|
||||||
_infoLocationNotifier.dispose();
|
_infoLocationNotifier.dispose();
|
||||||
|
// provided collection should be a new instance specifically created
|
||||||
|
// for the `LocationPickPage` widget, so it can be safely disposed here
|
||||||
|
widget.collection?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue