Merge branch 'develop'
2
.flutter
|
@ -1 +1 @@
|
||||||
Subproject commit 2f708eb8396e362e280fac22cf171c2cb467343c
|
Subproject commit 9e1c857886f07d342cf106f2cd588bcd5e031bb2
|
23
CHANGELOG.md
|
@ -4,6 +4,25 @@ 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.10.0"></a>[v1.10.0] - 2023-12-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Viewer / Slideshow: cast images via DLNA/UPnP
|
||||||
|
- Icelandic translation (thanks Sveinn í Felli)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- long press actions trigger haptic feedback according to OS settings
|
||||||
|
- target Android 14 (API 34)
|
||||||
|
- upgraded Flutter to stable v3.16.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- temporary files remaining in the cache directory forever
|
||||||
|
- detecting motion photos with more items in the XMP Container directory
|
||||||
|
- parsing EXIF date written as epoch time
|
||||||
|
|
||||||
## <a id="v1.9.7"></a>[v1.9.7] - 2023-10-17
|
## <a id="v1.9.7"></a>[v1.9.7] - 2023-10-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -15,12 +34,12 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
- mosaic layout: clamp ratio to 32/9
|
- mosaic layout: clamp ratio to 32/9
|
||||||
- Video: disable subtitles by default
|
- Video: disable subtitles by default
|
||||||
- Map: Stamen Watercolor layer (no longer served for free by Stamen) now served by Smithsonian Institution
|
- Map: Stamen Watercolor layer (no longer hosted for free by Stamen) now hosted by Smithsonian Institution
|
||||||
- upgraded Flutter to stable v3.13.7
|
- upgraded Flutter to stable v3.13.7
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Map: Stamen Toner layer (no longer served for free by Stamen)
|
- Map: Stamen Toner layer (no longer hosted for free by Stamen)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|
|
@ -76,14 +76,14 @@ android {
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId packageName
|
applicationId packageName
|
||||||
// minSdkVersion constraints:
|
// minSdk constraints:
|
||||||
// - Flutter & other plugins: 16
|
// - Flutter & other plugins: 16
|
||||||
// - google_maps_flutter v2.1.1: 20
|
// - google_maps_flutter v2.1.1: 20
|
||||||
// - to build XML documents from XMP data, `metadata-extractor` and `PixyMeta` rely on `DocumentBuilder`,
|
// - to build XML documents from XMP data, `metadata-extractor` and `PixyMeta` rely on `DocumentBuilder`,
|
||||||
// which implementation `DocumentBuilderImpl` is provided by the OS and is not customizable on Android,
|
// which implementation `DocumentBuilderImpl` is provided by the OS and is not customizable on Android,
|
||||||
// but the implementation on API <19 is not robust enough and fails to build XMP documents
|
// but the implementation on API <19 is not robust enough and fails to build XMP documents
|
||||||
minSdkVersion 19
|
minSdk 19
|
||||||
targetSdkVersion 33
|
targetSdk 34
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>",
|
manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: "<NONE>",
|
||||||
|
@ -216,14 +216,14 @@ dependencies {
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
implementation 'androidx.core:core-ktx:1.12.0'
|
||||||
implementation 'androidx.exifinterface:exifinterface:1.3.6'
|
implementation 'androidx.exifinterface:exifinterface:1.3.6'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
||||||
implementation 'androidx.media:media:1.6.0'
|
implementation 'androidx.media:media:1.7.0'
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
implementation 'androidx.security:security-crypto:1.1.0-alpha06'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
||||||
|
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
implementation 'com.commonsware.cwac:document:0.5.0'
|
implementation 'com.commonsware.cwac:document:0.5.0'
|
||||||
implementation 'com.drewnoakes:metadata-extractor:2.18.0'
|
implementation 'com.drewnoakes:metadata-extractor:2.19.0'
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
// SLF4J implementation for `mp4parser`
|
// SLF4J implementation for `mp4parser`
|
||||||
implementation 'org.slf4j:slf4j-simple:2.0.9'
|
implementation 'org.slf4j:slf4j-simple:2.0.9'
|
||||||
|
@ -240,7 +240,7 @@ dependencies {
|
||||||
// huawei flavor only
|
// huawei flavor only
|
||||||
huaweiImplementation "com.huawei.agconnect:agconnect-core:$huawei_agconnect_version"
|
huaweiImplementation "com.huawei.agconnect:agconnect-core:$huawei_agconnect_version"
|
||||||
|
|
||||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
|
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
|
||||||
|
|
||||||
kapt 'androidx.annotation:annotation:1.7.0'
|
kapt 'androidx.annotation:annotation:1.7.0'
|
||||||
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
ksp "com.github.bumptech.glide:ksp:$glide_version"
|
||||||
|
|
|
@ -33,10 +33,9 @@
|
||||||
<!-- to access media with original metadata with scoped storage (API >=29) -->
|
<!-- to access media with original metadata with scoped storage (API >=29) -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||||
<!-- to provide a foreground service type, as required by Android 14 (API 34) -->
|
<!-- to provide a foreground service type, as required by Android 14 (API 34) -->
|
||||||
<!-- TODO TLAD revisit with Android 14 >beta5 -->
|
<uses-permission
|
||||||
<!-- <uses-permission-->
|
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||||
<!-- android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"-->
|
tools:ignore="SystemPermissionTypo" />
|
||||||
<!-- tools:ignore="SystemPermissionTypo" />-->
|
|
||||||
<!-- TODO TLAD still needed to fetch map tiles / reverse geocoding / else ? check in release mode -->
|
<!-- TODO TLAD still needed to fetch map tiles / reverse geocoding / else ? check in release mode -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<!-- from Android 12 (API 31), users can optionally grant access to the media management special permission -->
|
<!-- from Android 12 (API 31), users can optionally grant access to the media management special permission -->
|
||||||
|
@ -69,7 +68,7 @@
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
allow install on API 19, despite the `minSdkVersion` declared in dependencies:
|
allow install on API 19, despite the `minSdk` declared in dependencies:
|
||||||
- Google Maps is from API 20
|
- Google Maps is from API 20
|
||||||
- the Security library is from API 21
|
- the Security library is from API 21
|
||||||
- FFmpegKit for Flutter is from API 24 (when not LTS)
|
- FFmpegKit for Flutter is from API 24 (when not LTS)
|
||||||
|
@ -102,18 +101,24 @@
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
as of Flutter v3.16.0, predictive back gesture does not work
|
||||||
|
as expected when extending `FlutterFragmentActivity`
|
||||||
|
so we disable `enableOnBackInvokedCallback`
|
||||||
|
-->
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:appCategory="image"
|
android:appCategory="image"
|
||||||
android:banner="@drawable/banner"
|
android:banner="@drawable/banner"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:fullBackupContent="@xml/full_backup_content"
|
android:fullBackupContent="@xml/full_backup_content"
|
||||||
android:fullBackupOnly="true"
|
android:fullBackupOnly="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
tools:targetApi="s">
|
tools:targetApi="tiramisu">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
@ -248,6 +253,12 @@
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
<!-- anonymous service for analysis worker is specified here to provide service type -->
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".MediaPlaybackService"
|
android:name=".MediaPlaybackService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
@ -304,8 +315,8 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
<!-- as of Flutter v3.10.1 (stable) / v3.12.0-15.0.pre.105 (master),
|
<!-- as of Flutter v3.16.0 (stable),
|
||||||
Impeller badly renders text, fails to render videos, and crashes with Google Maps -->
|
Impeller fails to render videos & platform views, has poor performance -->
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||||
android:value="false" />
|
android:value="false" />
|
||||||
|
|
|
@ -159,17 +159,15 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
||||||
.setContentIntent(openAppIntent)
|
.setContentIntent(openAppIntent)
|
||||||
.addAction(stopAction)
|
.addAction(stopAction)
|
||||||
.build()
|
.build()
|
||||||
// TODO TLAD revisit with Android 14 >beta5
|
return if (Build.VERSION.SDK_INT >= 34) {
|
||||||
return ForegroundInfo(NOTIFICATION_ID, notification);
|
// from Android 14 (API 34), foreground service type is mandatory
|
||||||
// return if (Build.VERSION.SDK_INT >= 34) {
|
// despite the sample code omitting it at:
|
||||||
// // as of Android 14 beta 5, foreground service type is mandatory
|
// https://developer.android.com/guide/background/persistent/how-to/long-running
|
||||||
// // despite the sample code omitting it at:
|
val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
// // https://developer.android.com/guide/background/persistent/how-to/long-running
|
ForegroundInfo(NOTIFICATION_ID, notification, type)
|
||||||
// val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
} else {
|
||||||
// ForegroundInfo(NOTIFICATION_ID, notification, type)
|
ForegroundInfo(NOTIFICATION_ID, notification)
|
||||||
// } else {
|
}
|
||||||
// ForegroundInfo(NOTIFICATION_ID, notification)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -33,7 +33,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
|
@ -279,8 +278,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||||
embeddedByteLength: Long,
|
embeddedByteLength: Long,
|
||||||
) {
|
) {
|
||||||
val extension = extensionFor(mimeType)
|
val extension = extensionFor(mimeType)
|
||||||
val targetFile = File.createTempFile("aves", extension, context.cacheDir).apply {
|
val targetFile = StorageUtils.createTempFile(context, extension).apply {
|
||||||
deleteOnExit()
|
|
||||||
transferFrom(embeddedByteStream, embeddedByteLength)
|
transferFrom(embeddedByteStream, embeddedByteLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
"getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
|
"getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
|
||||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||||
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
|
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
|
||||||
|
"deleteTempDirectory" -> ioScope.launch { safe(call, result, ::deleteTempDirectory) }
|
||||||
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
|
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
|
||||||
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
|
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
|
@ -200,6 +201,10 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
result.success(deleted)
|
result.success(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun deleteTempDirectory(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
result.success(StorageUtils.deleteTempDirectory(context))
|
||||||
|
}
|
||||||
|
|
||||||
private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,6 @@ import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||||
import deckers.thibault.aves.utils.MimeTypes
|
import deckers.thibault.aves.utils.MimeTypes
|
||||||
import deckers.thibault.aves.utils.StorageUtils
|
import deckers.thibault.aves.utils.StorageUtils
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import java.io.File
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class RegionFetcher internal constructor(
|
class RegionFetcher internal constructor(
|
||||||
|
@ -113,8 +112,7 @@ class RegionFetcher internal constructor(
|
||||||
.submit()
|
.submit()
|
||||||
try {
|
try {
|
||||||
val bitmap = target.get()
|
val bitmap = target.get()
|
||||||
val tempFile = File.createTempFile("aves", null, context.cacheDir).apply {
|
val tempFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||||
}
|
}
|
||||||
|
|
|
@ -388,7 +388,7 @@ enum class DirType {
|
||||||
override fun createDirectory() = ExifIFD0Directory()
|
override fun createDirectory() = ExifIFD0Directory()
|
||||||
},
|
},
|
||||||
EXIF_THUMBNAIL {
|
EXIF_THUMBNAIL {
|
||||||
override fun createDirectory() = ExifThumbnailDirectory()
|
override fun createDirectory() = ExifThumbnailDirectory(0)
|
||||||
},
|
},
|
||||||
GPS {
|
GPS {
|
||||||
override fun createDirectory() = GpsDirectory()
|
override fun createDirectory() = GpsDirectory()
|
||||||
|
|
|
@ -18,7 +18,6 @@ object ExifTags {
|
||||||
private const val SAMPLE_FORMAT = 0x0153
|
private const val SAMPLE_FORMAT = 0x0153
|
||||||
private const val SMIN_SAMPLE_VALUE = 0x0154
|
private const val SMIN_SAMPLE_VALUE = 0x0154
|
||||||
private const val SMAX_SAMPLE_VALUE = 0x0155
|
private const val SMAX_SAMPLE_VALUE = 0x0155
|
||||||
private const val RATING_PERCENT = 0x4749
|
|
||||||
private const val SONY_RAW_FILE_TYPE = 0x7000
|
private const val SONY_RAW_FILE_TYPE = 0x7000
|
||||||
private const val SONY_TONE_CURVE = 0x7010
|
private const val SONY_TONE_CURVE = 0x7010
|
||||||
private const val MATTEING = 0x80e3
|
private const val MATTEING = 0x80e3
|
||||||
|
@ -40,7 +39,6 @@ object ExifTags {
|
||||||
SAMPLE_FORMAT to "Sample Format",
|
SAMPLE_FORMAT to "Sample Format",
|
||||||
SMIN_SAMPLE_VALUE to "S Min Sample Value",
|
SMIN_SAMPLE_VALUE to "S Min Sample Value",
|
||||||
SMAX_SAMPLE_VALUE to "S Max Sample Value",
|
SMAX_SAMPLE_VALUE to "S Max Sample Value",
|
||||||
RATING_PERCENT to "Rating Percent",
|
|
||||||
SONY_RAW_FILE_TYPE to "Sony Raw File Type",
|
SONY_RAW_FILE_TYPE to "Sony Raw File Type",
|
||||||
SONY_TONE_CURVE to "Sony Tone Curve",
|
SONY_TONE_CURVE to "Sony Tone Curve",
|
||||||
MATTEING to "Matteing",
|
MATTEING to "Matteing",
|
||||||
|
|
|
@ -160,8 +160,7 @@ object Metadata {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createPreviewFile(context: Context, uri: Uri): File {
|
fun createPreviewFile(context: Context, uri: Uri): File {
|
||||||
return File.createTempFile("aves", null, context.cacheDir).apply {
|
return StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE)
|
transferFrom(StorageUtils.openInputStream(context, uri), PREVIEW_SIZE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -174,9 +174,7 @@ object MultiPage {
|
||||||
} else if (xmpMeta.doesPropExist(XMP.GCONTAINER_DIRECTORY_PROP_NAME)) {
|
} else if (xmpMeta.doesPropExist(XMP.GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||||
// `Container` motion photo
|
// `Container` motion photo
|
||||||
val count = xmpMeta.countPropArrayItems(XMP.GCONTAINER_DIRECTORY_PROP_NAME)
|
val count = xmpMeta.countPropArrayItems(XMP.GCONTAINER_DIRECTORY_PROP_NAME)
|
||||||
if (count == 2) {
|
for (i in 1 until count + 1) {
|
||||||
// expect the video to be the second item
|
|
||||||
val i = 2
|
|
||||||
val mime = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_MIME_PROP_NAME))?.value
|
val mime = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||||
val length = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
val length = xmpMeta.getSafeStructField(listOf(XMP.GCONTAINER_DIRECTORY_PROP_NAME, i, XMP.GCONTAINER_ITEM_PROP_NAME, XMP.GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
||||||
if (MimeTypes.isVideo(mime) && length != null) {
|
if (MimeTypes.isVideo(mime) && length != null) {
|
||||||
|
|
|
@ -188,17 +188,15 @@ object XMP {
|
||||||
// Container motion photo
|
// Container motion photo
|
||||||
if (doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
if (doesPropExist(GCONTAINER_DIRECTORY_PROP_NAME)) {
|
||||||
val count = countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
val count = countPropArrayItems(GCONTAINER_DIRECTORY_PROP_NAME)
|
||||||
if (count == 2) {
|
var hasImage = false
|
||||||
var hasImage = false
|
var hasVideo = false
|
||||||
var hasVideo = false
|
for (i in 1 until count + 1) {
|
||||||
for (i in 1 until count + 1) {
|
val mime = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_MIME_PROP_NAME))?.value
|
||||||
val mime = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_MIME_PROP_NAME))?.value
|
val length = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
||||||
val length = getSafeStructField(listOf(GCONTAINER_DIRECTORY_PROP_NAME, i, GCONTAINER_ITEM_PROP_NAME, GCONTAINER_ITEM_LENGTH_PROP_NAME))?.value
|
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
||||||
hasImage = hasImage || MimeTypes.isImage(mime) && length != null
|
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
||||||
hasVideo = hasVideo || MimeTypes.isVideo(mime) && length != null
|
|
||||||
}
|
|
||||||
if (hasImage && hasVideo) return true
|
|
||||||
}
|
}
|
||||||
|
if (hasImage && hasVideo) return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -31,8 +31,14 @@ import deckers.thibault.aves.utils.LogUtils
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.text.ParseException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.GregorianCalendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
object Helper {
|
object Helper {
|
||||||
private val LOG_TAG = LogUtils.createTag<Helper>()
|
private val LOG_TAG = LogUtils.createTag<Helper>()
|
||||||
|
@ -110,7 +116,7 @@ object Helper {
|
||||||
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
|
fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata {
|
||||||
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
|
val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength)
|
||||||
val metadata = com.drew.metadata.Metadata()
|
val metadata = com.drew.metadata.Metadata()
|
||||||
val handler = SafeExifTiffHandler(metadata, null)
|
val handler = SafeExifTiffHandler(metadata, null, 0)
|
||||||
TiffReader().processTiff(reader, handler, 0)
|
TiffReader().processTiff(reader, handler, 0)
|
||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
@ -150,12 +156,105 @@ object Helper {
|
||||||
|
|
||||||
fun Directory.getSafeDateMillis(tag: Int, subSecond: String?): Long? {
|
fun Directory.getSafeDateMillis(tag: Int, subSecond: String?): Long? {
|
||||||
if (this.containsTag(tag)) {
|
if (this.containsTag(tag)) {
|
||||||
val date = this.getDate(tag, subSecond, TimeZone.getDefault())
|
val date = this.getDatePlus(tag, subSecond, TimeZone.getDefault())
|
||||||
if (date != null) return date.time
|
if (date != null) return date.time
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This seems to cover all known Exif and Xmp date strings
|
||||||
|
// Note that " : : : : " is a valid date string according to the Exif spec (which means 'unknown date'): http://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/datetimeoriginal.html
|
||||||
|
private val datePatterns = arrayOf(
|
||||||
|
"yyyy:MM:dd HH:mm:ss",
|
||||||
|
"yyyy:MM:dd HH:mm",
|
||||||
|
"yyyy-MM-dd HH:mm:ss",
|
||||||
|
"yyyy-MM-dd HH:mm",
|
||||||
|
"yyyy.MM.dd HH:mm:ss",
|
||||||
|
"yyyy.MM.dd HH:mm",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm",
|
||||||
|
"yyyy-MM-dd",
|
||||||
|
"yyyy-MM",
|
||||||
|
"yyyyMMdd", // as used in IPTC data
|
||||||
|
"yyyy"
|
||||||
|
)
|
||||||
|
private val subsecondPattern = Pattern.compile("(\\d\\d:\\d\\d:\\d\\d)(\\.\\d+)")
|
||||||
|
private val timeZonePattern = Pattern.compile("(Z|[+-]\\d\\d:\\d\\d|[+-]\\d\\d\\d\\d)$")
|
||||||
|
private val calendar: Calendar = GregorianCalendar()
|
||||||
|
private const val PARSED_DATE_YEAR_MAX = 10000
|
||||||
|
|
||||||
|
// adapted from `metadata-extractor` v2.18.0 `Directory.getDate()`
|
||||||
|
// to also parse dates written as timestamps
|
||||||
|
private fun Directory.getDatePlus(tagType: Int, subSecond: String?, timeZone: TimeZone?): Date? {
|
||||||
|
var effectiveSubSecond = subSecond
|
||||||
|
var effectiveTimeZone = timeZone
|
||||||
|
val o = this.getObject(tagType)
|
||||||
|
if (o is Date) return o
|
||||||
|
|
||||||
|
var date: Date? = null
|
||||||
|
if (o is String || o is StringValue) {
|
||||||
|
var dateString = o.toString()
|
||||||
|
|
||||||
|
// if the date string has subsecond information, it supersedes the subsecond parameter
|
||||||
|
val subsecondMatcher = subsecondPattern.matcher(dateString)
|
||||||
|
if (subsecondMatcher.find()) {
|
||||||
|
effectiveSubSecond = subsecondMatcher.group(2)?.substring(1)
|
||||||
|
dateString = subsecondMatcher.replaceAll("$1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the date string has time zone information, it supersedes the timeZone parameter
|
||||||
|
val timeZoneMatcher = timeZonePattern.matcher(dateString)
|
||||||
|
if (timeZoneMatcher.find()) {
|
||||||
|
effectiveTimeZone = TimeZone.getTimeZone("GMT" + timeZoneMatcher.group().replace("Z".toRegex(), ""))
|
||||||
|
dateString = timeZoneMatcher.replaceAll("")
|
||||||
|
}
|
||||||
|
for (datePattern in datePatterns) {
|
||||||
|
try {
|
||||||
|
val parsed = SimpleDateFormat(datePattern, Locale.ROOT).apply {
|
||||||
|
this.timeZone = effectiveTimeZone ?: TimeZone.getTimeZone("GMT") // don't interpret zone time
|
||||||
|
}.parse(dateString)
|
||||||
|
if (parsed != null) {
|
||||||
|
calendar.time = parsed
|
||||||
|
if (calendar.get(Calendar.YEAR) < PARSED_DATE_YEAR_MAX) {
|
||||||
|
date = parsed
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: ParseException) {
|
||||||
|
// simply try the next pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (date == null) {
|
||||||
|
val dateLong = dateString.toLongOrNull()
|
||||||
|
if (dateLong != null) {
|
||||||
|
val epochTimeMillis = when (dateLong) {
|
||||||
|
in 0..99999999999 -> dateLong * 1000 // seconds
|
||||||
|
in 100000000000..99999999999999 -> dateLong // millis
|
||||||
|
in 100000000000000..9999999999999999 -> dateLong / 1000 // micros
|
||||||
|
else -> dateLong / 1000000 // nanos
|
||||||
|
}
|
||||||
|
date = Date(epochTimeMillis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (date == null) return null
|
||||||
|
|
||||||
|
if (effectiveSubSecond != null) {
|
||||||
|
try {
|
||||||
|
val millisecond = (".$effectiveSubSecond".toDouble() * 1000).toInt()
|
||||||
|
if (millisecond in 0..999) {
|
||||||
|
val calendar = Calendar.getInstance()
|
||||||
|
calendar.time = date
|
||||||
|
calendar[Calendar.MILLISECOND] = millisecond
|
||||||
|
return calendar.time
|
||||||
|
}
|
||||||
|
} catch (e: NumberFormatException) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
// time tag and sub-second tag are *not* in the same directory
|
// time tag and sub-second tag are *not* in the same directory
|
||||||
fun ExifSubIFDDirectory.getDateModifiedMillis(save: (value: Long) -> Unit) {
|
fun ExifSubIFDDirectory.getDateModifiedMillis(save: (value: Long) -> Unit) {
|
||||||
val parent = parent
|
val parent = parent
|
||||||
|
|
|
@ -8,7 +8,7 @@ import com.drew.metadata.exif.ExifSubIFDDirectory
|
||||||
import com.drew.metadata.exif.ExifTiffHandler
|
import com.drew.metadata.exif.ExifTiffHandler
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class SafeExifTiffHandler(metadata: Metadata, parentDirectory: Directory?) : ExifTiffHandler(metadata, parentDirectory) {
|
class SafeExifTiffHandler(metadata: Metadata, parentDirectory: Directory?, exifStartOffset: Int) : ExifTiffHandler(metadata, parentDirectory, exifStartOffset) {
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun customProcessTag(
|
override fun customProcessTag(
|
||||||
tagOffset: Int,
|
tagOffset: Int,
|
||||||
|
|
|
@ -45,6 +45,7 @@ object SafePngMetadataReader {
|
||||||
private const val chunkSizeDangerThreshold = SafeXmpReader.SEGMENT_TYPE_SIZE_DANGER_THRESHOLD
|
private const val chunkSizeDangerThreshold = SafeXmpReader.SEGMENT_TYPE_SIZE_DANGER_THRESHOLD
|
||||||
|
|
||||||
private val latin1Encoding = Charsets.ISO_8859_1
|
private val latin1Encoding = Charsets.ISO_8859_1
|
||||||
|
private val utf8Encoding = Charsets.UTF_8
|
||||||
private val desiredChunkTypes: Set<PngChunkType> = hashSetOf(
|
private val desiredChunkTypes: Set<PngChunkType> = hashSetOf(
|
||||||
PngChunkType.IHDR,
|
PngChunkType.IHDR,
|
||||||
PngChunkType.PLTE,
|
PngChunkType.PLTE,
|
||||||
|
@ -234,7 +235,7 @@ object SafePngMetadataReader {
|
||||||
val reader: SequentialReader = SequentialByteArrayReader(bytes)
|
val reader: SequentialReader = SequentialByteArrayReader(bytes)
|
||||||
|
|
||||||
// Keyword is 1-79 bytes, followed by the 1 byte null character
|
// Keyword is 1-79 bytes, followed by the 1 byte null character
|
||||||
val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding)
|
val keywordsv = reader.getNullTerminatedStringValue(79 + 1, utf8Encoding)
|
||||||
val keyword = keywordsv.toString()
|
val keyword = keywordsv.toString()
|
||||||
val compressionFlag = reader.int8
|
val compressionFlag = reader.int8
|
||||||
val compressionMethod = reader.int8
|
val compressionMethod = reader.int8
|
||||||
|
@ -273,7 +274,7 @@ object SafePngMetadataReader {
|
||||||
XmpReader().extract(textBytes, metadata)
|
XmpReader().extract(textBytes, metadata)
|
||||||
} else {
|
} else {
|
||||||
val textPairs: MutableList<KeyValuePair> = ArrayList()
|
val textPairs: MutableList<KeyValuePair> = ArrayList()
|
||||||
textPairs.add(KeyValuePair(keyword, StringValue(textBytes, latin1Encoding)))
|
textPairs.add(KeyValuePair(keyword, StringValue(textBytes, utf8Encoding)))
|
||||||
val directory = PngDirectory(PngChunkType.iTXt)
|
val directory = PngDirectory(PngChunkType.iTXt)
|
||||||
directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs)
|
directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs)
|
||||||
metadata.addDirectory(directory)
|
metadata.addDirectory(directory)
|
||||||
|
@ -316,7 +317,7 @@ object SafePngMetadataReader {
|
||||||
metadata.addDirectory(directory)
|
metadata.addDirectory(directory)
|
||||||
} else if (chunkType == PngChunkType.eXIf) {
|
} else if (chunkType == PngChunkType.eXIf) {
|
||||||
try {
|
try {
|
||||||
val handler = ExifTiffHandler(metadata, null)
|
val handler = ExifTiffHandler(metadata, null, 0)
|
||||||
TiffReader().processTiff(ByteArrayReader(bytes), handler, 0)
|
TiffReader().processTiff(ByteArrayReader(bytes), handler, 0)
|
||||||
} catch (ex: TiffProcessingException) {
|
} catch (ex: TiffProcessingException) {
|
||||||
val directory = PngDirectory(PngChunkType.eXIf)
|
val directory = PngDirectory(PngChunkType.eXIf)
|
||||||
|
|
|
@ -381,8 +381,7 @@ abstract class ImageProvider {
|
||||||
targetUri: Uri,
|
targetUri: Uri,
|
||||||
targetPath: String,
|
targetPath: String,
|
||||||
) {
|
) {
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
// copy original file to a temporary file for editing
|
// copy original file to a temporary file for editing
|
||||||
val inputStream = StorageUtils.openInputStream(context, targetUri)
|
val inputStream = StorageUtils.openInputStream(context, targetUri)
|
||||||
transferFrom(inputStream, File(targetPath).length())
|
transferFrom(inputStream, File(targetPath).length())
|
||||||
|
@ -514,8 +513,7 @@ abstract class ImageProvider {
|
||||||
output.write(bytes)
|
output.write(bytes)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val editableFile = withContext(Dispatchers.IO) { File.createTempFile("aves", null) }.apply {
|
val editableFile = withContext(Dispatchers.IO) { StorageUtils.createTempFile(contextWrapper) }.apply {
|
||||||
deleteOnExit()
|
|
||||||
transferFrom(ByteArrayInputStream(bytes), bytes.size.toLong())
|
transferFrom(ByteArrayInputStream(bytes), bytes.size.toLong())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -649,8 +647,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.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
var videoBytes: ByteArray? = null
|
var videoBytes: ByteArray? = null
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
try {
|
try {
|
||||||
if (videoSize != null) {
|
if (videoSize != null) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
|
@ -733,8 +730,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.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
var videoBytes: ByteArray? = null
|
var videoBytes: ByteArray? = null
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
try {
|
try {
|
||||||
if (videoSize != null) {
|
if (videoSize != null) {
|
||||||
// handle motion photo and embedded video separately
|
// handle motion photo and embedded video separately
|
||||||
|
@ -898,8 +894,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.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
try {
|
try {
|
||||||
editXmpWithPixy(
|
editXmpWithPixy(
|
||||||
context = context,
|
context = context,
|
||||||
|
@ -1275,8 +1270,7 @@ abstract class ImageProvider {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
try {
|
try {
|
||||||
val inputStream = StorageUtils.openInputStream(context, uri)
|
val inputStream = StorageUtils.openInputStream(context, uri)
|
||||||
// partial copy
|
// partial copy
|
||||||
|
@ -1316,8 +1310,7 @@ 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.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||||
val editableFile = File.createTempFile("aves", null).apply {
|
val editableFile = StorageUtils.createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
try {
|
try {
|
||||||
outputStream().use { output ->
|
outputStream().use { output ->
|
||||||
// reopen input to read from start
|
// reopen input to read from start
|
||||||
|
|
|
@ -725,12 +725,19 @@ class MediaStoreImageProvider : ImageProvider() {
|
||||||
val df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)
|
val df = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)
|
||||||
df ?: throw Exception("failed to get document at path=$oldPath")
|
df ?: throw Exception("failed to get document at path=$oldPath")
|
||||||
|
|
||||||
|
val requestedName = newFile.name
|
||||||
val renamed = df.renameTo(newFile.name)
|
val renamed = df.renameTo(newFile.name)
|
||||||
if (!renamed) {
|
if (!renamed) {
|
||||||
throw Exception("failed to rename document at path=$oldPath")
|
throw Exception("failed to rename document at path=$oldPath")
|
||||||
}
|
}
|
||||||
|
val effectiveName = df.name
|
||||||
|
if (requestedName != effectiveName) {
|
||||||
|
Log.w(LOG_TAG, "requested renaming document at uri=$oldMediaUri path=$oldPath with name=${requestedName} but got name=$effectiveName")
|
||||||
|
}
|
||||||
|
val newPath = File(newFile.parentFile, df.name).path
|
||||||
|
|
||||||
scanObsoletePath(activity, oldMediaUri, oldPath, mimeType)
|
scanObsoletePath(activity, oldMediaUri, oldPath, mimeType)
|
||||||
return scanNewPathByMediaStore(activity, newFile.path, mimeType)
|
return scanNewPathByMediaStore(activity, newPath, mimeType)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun renameSingleByFile(
|
private suspend fun renameSingleByFile(
|
||||||
|
|
|
@ -26,6 +26,7 @@ import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
@ -593,8 +594,7 @@ object StorageUtils {
|
||||||
uriPath?.contains("/file/") == true -> {
|
uriPath?.contains("/file/") == true -> {
|
||||||
// e.g. `content://media/external/file/...`
|
// e.g. `content://media/external/file/...`
|
||||||
// create an ad-hoc temporary file for decoding only
|
// create an ad-hoc temporary file for decoding only
|
||||||
File.createTempFile("aves", null).apply {
|
createTempFile(context).apply {
|
||||||
deleteOnExit()
|
|
||||||
try {
|
try {
|
||||||
transferFrom(openInputStream(context, uri), sizeBytes)
|
transferFrom(openInputStream(context, uri), sizeBytes)
|
||||||
return Uri.fromFile(this)
|
return Uri.fromFile(this)
|
||||||
|
@ -714,6 +714,25 @@ object StorageUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getTempDirectory(context: Context): File = File(context.cacheDir, "temp")
|
||||||
|
|
||||||
|
fun createTempFile(context: Context, extension: String? = null): File {
|
||||||
|
val directory = getTempDirectory(context)
|
||||||
|
if (!directory.exists() && !directory.mkdirs()) {
|
||||||
|
throw IOException("failed to create directories at path=$directory")
|
||||||
|
}
|
||||||
|
val tempFile = File.createTempFile("aves", extension, directory)
|
||||||
|
// `deleteOnExit` is unreliable, but it does not hurt
|
||||||
|
tempFile.deleteOnExit()
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteTempDirectory(context: Context): Boolean {
|
||||||
|
val directory = getTempDirectory(context)
|
||||||
|
if (!directory.exists()) return false
|
||||||
|
return directory.deleteRecursively()
|
||||||
|
}
|
||||||
|
|
||||||
// convenience methods
|
// convenience methods
|
||||||
|
|
||||||
fun getFolderSize(f: File): Long {
|
fun getFolderSize(f: File): Long {
|
||||||
|
|
12
android/app/src/main/res/values-is/strings.xml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="analysis_notification_action_stop">Stöðva</string>
|
||||||
|
<string name="app_widget_label">Myndarammi</string>
|
||||||
|
<string name="analysis_notification_default_title">Skanna myndefni</string>
|
||||||
|
<string name="videos_shortcut_short_label">Myndskeið</string>
|
||||||
|
<string name="safe_mode_shortcut_short_label">Öruggur hamur</string>
|
||||||
|
<string name="wallpaper">Bakgrunnur</string>
|
||||||
|
<string name="app_name">Aves</string>
|
||||||
|
<string name="analysis_channel_name">Skönnun myndefnis</string>
|
||||||
|
<string name="search_shortcut_short_label">Leita</string>
|
||||||
|
</resources>
|
|
@ -1,8 +1,8 @@
|
||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
kotlin_version = '1.8.21'
|
kotlin_version = '1.9.21'
|
||||||
ksp_version = "$kotlin_version-1.0.11"
|
ksp_version = "$kotlin_version-1.0.15"
|
||||||
agp_version = '7.4.2'
|
agp_version = '8.1.4'
|
||||||
glide_version = '4.16.0'
|
glide_version = '4.16.0'
|
||||||
// AppGallery Connect plugin versions: https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-sdk-changenotes-0000001058732550
|
// AppGallery Connect plugin versions: https://developer.huawei.com/consumer/en/doc/development/AppGallery-connect-Guides/agc-sdk-changenotes-0000001058732550
|
||||||
huawei_agconnect_version = '1.9.1.300'
|
huawei_agconnect_version = '1.9.1.300'
|
||||||
|
@ -27,8 +27,8 @@ buildscript {
|
||||||
|
|
||||||
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.3.15'
|
classpath 'com.google.gms:google-services:4.4.0'
|
||||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.8'
|
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.9'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useHms) {
|
if (useHms) {
|
||||||
|
|
|
@ -11,8 +11,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
# Android operating system, and which are packaged with your app"s APK
|
# Android operating system, and which are packaged with your app"s APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# Automatically convert third-party libraries to use AndroidX
|
|
||||||
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.defaults.buildfeatures.buildconfig=true
|
||||||
|
|
|
@ -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.0-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
|
||||||
|
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 280 KiB |
Before Width: | Height: | Size: 498 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 201 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 326 KiB |
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 279 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 497 KiB |
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 327 KiB |
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 281 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 205 KiB |
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 327 KiB |
Before Width: | Height: | Size: 346 KiB After Width: | Height: | Size: 337 KiB |
|
@ -1,5 +0,0 @@
|
||||||
In v1.8.9:
|
|
||||||
- play your animated PNGs
|
|
||||||
- set your home to the Tags page
|
|
||||||
- enjoy the app in Norwegian (Nynorsk)
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.8.9:
|
|
||||||
- play your animated PNGs
|
|
||||||
- set your home to the Tags page
|
|
||||||
- enjoy the app in Norwegian (Nynorsk)
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.0:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.0:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.1:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.1:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.2:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.2:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.3:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
|
@ -1,5 +0,0 @@
|
||||||
In v1.9.3:
|
|
||||||
- play your animated AVIF, AV1, and HDR videos
|
|
||||||
- filter by rating ranges
|
|
||||||
- judge tonal distributions with the viewer histogram
|
|
||||||
Full changelog available on GitHub
|
|
4
fastlane/metadata/android/en-US/changelogs/109.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
In v1.10.0:
|
||||||
|
- cast images via DLNA/UPnP
|
||||||
|
- enjoy the app in Icelandic
|
||||||
|
Full changelog available on GitHub
|
4
fastlane/metadata/android/en-US/changelogs/10901.txt
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
In v1.10.0:
|
||||||
|
- cast images via DLNA/UPnP
|
||||||
|
- enjoy the app in Icelandic
|
||||||
|
Full changelog available on GitHub
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 280 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 199 KiB |
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 118 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 82 KiB |
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 328 KiB |
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 282 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 203 KiB |
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 120 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 327 KiB |
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 284 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 202 KiB |
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 85 KiB |
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 328 KiB |
Before Width: | Height: | Size: 340 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 279 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 201 KiB |
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 120 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 326 KiB |
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 281 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 497 KiB |
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 119 KiB |
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 328 KiB |
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 337 KiB |
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 278 KiB |
Before Width: | Height: | Size: 499 KiB After Width: | Height: | Size: 496 KiB |
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 200 KiB |
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 117 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 327 KiB |
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 337 KiB |
5
fastlane/metadata/android/is/full_description.txt
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
<i>Aves</i> can handle all sorts of images and videos, including your typical JPEGs and MP4s, but also more exotic things like <b>multi-page TIFFs, SVGs, old AVIs and more</b>! It scans your media collection to identify <b>motion photos</b>, <b>panoramas</b> (aka photo spheres), <b>360° videos</b>, as well as <b>GeoTIFF</b> files.
|
||||||
|
|
||||||
|
<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 (from KitKat to Android 13, 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>.
|
BIN
fastlane/metadata/android/is/images/featureGraphic.png
Normal file
After Width: | Height: | Size: 16 KiB |