diff --git a/.flutter b/.flutter
index 135454af3..994429713 160000
--- a/.flutter
+++ b/.flutter
@@ -1 +1 @@
-Subproject commit 135454af32477f815a7525073027a3ff9eff1bfd
+Subproject commit 9944297138845a94256f1cf37beb88ff9a8e811a
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 896ce5ddd..b148456f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+## [v1.8.0] - 2023-02-20
+
+### Added
+
+- Vaults
+- Viewer: overlay details expand/collapse on tap
+- Viewer: export actions available as quick actions
+- Slideshow: added settings quick action
+- TV: improved support for Info
+- Basque translation (thanks Aitor Salaberria)
+
+### Changed
+
+- disabling the recycle bin will delete forever items in it
+- remember pin status of albums becoming empty
+- allow setting dates before 1970/01/01
+- upgraded Flutter to stable v3.7.3
+
+### Fixed
+
+- SD card access grant on Android Lollipop
+- copying to SD card in some cases
+- sharing SD card files referred by `file` URI
+
## [v1.7.10] - 2023-01-18
### Added
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 051231057..b974a5ee6 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -18,6 +18,9 @@ if (localPropertiesFile.exists()) {
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
def flutterVersionName = localProperties.getProperty('flutter.versionName')
def flutterRoot = localProperties.getProperty('flutter.sdk')
+if (flutterRoot == null) {
+ throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
+}
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
// Keys
@@ -181,10 +184,11 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.9.0'
- implementation 'androidx.exifinterface:exifinterface:1.3.5'
+ implementation 'androidx.exifinterface:exifinterface:1.3.6'
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
implementation 'androidx.media:media:1.6.0'
implementation 'androidx.multidex:multidex:2.0.1'
+ implementation 'androidx.security:security-crypto:1.1.0-alpha04'
implementation 'com.caverock:androidsvg-aar:1.4'
implementation 'com.commonsware.cwac:document:0.5.0'
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5d8bc1ce9..7553cb54f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -19,7 +19,7 @@ This change eventually prevents building the app with Flutter v3.3.3.
android:required="false" />
@@ -32,28 +32,35 @@ This change eventually prevents building the app with Flutter v3.3.3.
android:maxSdkVersion="29"
tools:ignore="ScopedStorage" />
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
-
-
+
+
@@ -75,12 +82,14 @@ This change eventually prevents building the app with Flutter v3.3.3.
android:allowBackup="true"
android:appCategory="image"
android:banner="@drawable/banner"
+ android:dataExtractionRules="@xml/data_extraction_rules"
+ android:fullBackupContent="@xml/full_backup_content"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
- tools:targetApi="o">
+ tools:targetApi="s">
when (call.method) {
"configure" -> {
@@ -42,9 +47,9 @@ class HomeWidgetSettingsActivity : MainActivity() {
}
private fun saveWidget() {
- val appWidgetManager = AppWidgetManager.getInstance(context)
+ val appWidgetManager = AppWidgetManager.getInstance(this)
val widgetInfo = appWidgetManager.getAppWidgetOptions(appWidgetId)
- HomeWidgetProvider().onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, widgetInfo)
+ HomeWidgetProvider().onAppWidgetOptionsChanged(this, appWidgetManager, appWidgetId, widgetInfo)
val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
setResult(RESULT_OK, intent)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt
index 307eadc55..20961ea40 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt
@@ -90,7 +90,7 @@ class HomeWidgetProvider : AppWidgetProvider() {
val messenger = flutterEngine!!.dartExecutor
val channel = MethodChannel(messenger, WIDGET_DRAW_CHANNEL)
try {
- val bytes = suspendCoroutine { cont ->
+ val bytes = suspendCoroutine { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
channel.invokeMethod("drawWidget", hashMapOf(
@@ -194,7 +194,10 @@ class HomeWidgetProvider : AppWidgetProvider() {
}
private fun initChannels(context: Context) {
- val messenger = flutterEngine!!.dartExecutor
+ val engine = flutterEngine
+ engine ?: throw Exception("Flutter engine is not initialized")
+
+ val messenger = engine.dartExecutor
// dart -> platform -> dart
// - need Context
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
index d4b3ee148..cfd60ec3e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt
@@ -25,14 +25,15 @@ import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import deckers.thibault.aves.utils.FlutterUtils.isSoftwareRenderingRequired
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat
-import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.android.FlutterFragmentActivity
+import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
-open class MainActivity : FlutterActivity() {
+open class MainActivity : FlutterFragmentActivity() {
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
private lateinit var intentStreamHandler: IntentStreamHandler
@@ -68,8 +69,12 @@ open class MainActivity : FlutterActivity() {
// .build()
// )
super.onCreate(savedInstanceState)
+ }
- val messenger = flutterEngine!!.dartExecutor
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+
+ val messenger = flutterEngine.dartExecutor
// notification: platform -> dart
analysisStreamHandler = AnalysisStreamHandler().apply {
@@ -99,6 +104,7 @@ open class MainActivity : FlutterActivity() {
MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler)
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
+ MethodChannel(messenger, SecurityHandler.CHANNEL).setMethodCallHandler(SecurityHandler(this))
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
// - need ContextWrapper
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
@@ -172,7 +178,18 @@ open class MainActivity : FlutterActivity() {
mediaSessionHandler.dispose()
mediaStoreChangeStreamHandler.dispose()
settingsChangeStreamHandler.dispose()
- super.onDestroy()
+ try {
+ super.onDestroy()
+ } catch (e: Exception) {
+ // on Android 11, app may crash as follows:
+ // `Fatal Exception:`
+ // `java.lang.RuntimeException: Unable to destroy activity {deckers.thibault.aves/deckers.thibault.aves.MainActivity}:`
+ // `java.lang.IllegalArgumentException: NetworkCallback was not registered`
+ // related to this error:
+ // `Package android does not belong to 10162`
+ // cf https://issuetracker.google.com/issues/175055271
+ Log.e(LOG_TAG, "failed while destroying activity", e)
+ }
}
override fun onNewIntent(intent: Intent) {
@@ -182,6 +199,7 @@ open class MainActivity : FlutterActivity() {
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data)
DELETE_SINGLE_PERMISSION_REQUEST,
@@ -244,7 +262,7 @@ open class MainActivity : FlutterActivity() {
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
(intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri ->
// MIME type is optional
- val type = intent.type ?: intent.resolveType(context)
+ val type = intent.type ?: intent.resolveType(this)
return hashMapOf(
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
INTENT_DATA_KEY_MIME_TYPE to type,
@@ -314,7 +332,7 @@ open class MainActivity : FlutterActivity() {
private fun submitPickedItems(call: MethodCall) {
val pickedUris = call.argument>("uris")
if (pickedUris != null && pickedUris.isNotEmpty()) {
- val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(context, Uri.parse(uriString)) }
+ val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
val intent = Intent().apply {
val firstUri = toUri(pickedUris.first())
if (pickedUris.size == 1) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt
index 4627eeb52..4429cd878 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt
@@ -72,7 +72,10 @@ class SearchSuggestionsProvider : ContentProvider() {
}
}
- val messenger = flutterEngine!!.dartExecutor
+ val engine = flutterEngine
+ engine ?: throw Exception("Flutter engine is not initialized")
+
+ val messenger = engine.dartExecutor
val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL).apply {
setMethodCallHandler { call, result ->
when (call.method) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt
index a4285c3cf..202e73090 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt
@@ -1,6 +1,5 @@
package deckers.thibault.aves
-import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
@@ -18,11 +17,12 @@ import deckers.thibault.aves.utils.FlutterUtils
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.getParcelableExtraCompat
-import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.android.FlutterFragmentActivity
+import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
-class WallpaperActivity : FlutterActivity() {
+class WallpaperActivity : FlutterFragmentActivity() {
private lateinit var intentDataMap: MutableMap
override fun onCreate(savedInstanceState: Bundle?) {
@@ -36,8 +36,33 @@ class WallpaperActivity : FlutterActivity() {
Log.i(LOG_TAG, "onCreate intent extras=$it")
}
intentDataMap = extractIntentData(intent)
+ }
- initChannels(this)
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ val messenger = flutterEngine.dartExecutor
+
+ // dart -> platform -> dart
+ // - need Context
+ MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
+ MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
+ MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(this))
+ MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this))
+ MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
+ MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
+ // - need ContextWrapper
+ MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
+ MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
+ // - need Activity
+ MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
+
+ // result streaming: dart -> platform ->->-> dart
+ // - need Context
+ StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
+
+ // intent handling
+ // detail fetch: dart -> platform
+ MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) }
}
override fun onStart() {
@@ -54,32 +79,6 @@ class WallpaperActivity : FlutterActivity() {
}
}
- private fun initChannels(activity: Activity) {
- val messenger = flutterEngine!!.dartExecutor
-
- // dart -> platform -> dart
- // - need Context
- MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(activity))
- MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(activity))
- MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(activity))
- MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(activity))
- MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(activity))
- MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(activity))
- // - need ContextWrapper
- MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(activity))
- MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(activity))
- // - need Activity
- MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(activity))
-
- // result streaming: dart -> platform ->->-> dart
- // - need Context
- StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(activity, args) }
-
- // intent handling
- // detail fetch: dart -> platform
- MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) }
- }
-
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getIntentData" -> {
@@ -94,7 +93,7 @@ class WallpaperActivity : FlutterActivity() {
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
(intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri ->
// MIME type is optional
- val type = intent.type ?: intent.resolveType(context)
+ val type = intent.type ?: intent.resolveType(this)
return hashMapOf(
MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SET_WALLPAPER,
MainActivity.INTENT_DATA_KEY_MIME_TYPE to type,
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt
index 9f59bb879..f35eaaa39 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt
@@ -21,7 +21,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
when (call.method) {
"canManageMedia" -> safe(call, result, ::canManageMedia)
"getCapabilities" -> safe(call, result, ::getCapabilities)
- "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
+ "getDefaultTimeZoneRawOffsetMillis" -> safe(call, result, ::getDefaultTimeZoneRawOffsetMillis)
"getLocales" -> safe(call, result, ::getLocales)
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
@@ -41,9 +41,10 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"canGrantDirectoryAccess" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
- "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
+ "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
+ "canUseCrypto" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
"hasGeocoder" to Geocoder.isPresent(),
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
@@ -52,8 +53,8 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
)
}
- private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
- result.success(TimeZone.getDefault().id)
+ private fun getDefaultTimeZoneRawOffsetMillis(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
+ result.success(TimeZone.getDefault().rawOffset)
}
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt
index e3373bcce..779213671 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaSessionHandler.kt
@@ -35,6 +35,15 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
}
fun dispose() {
+ unregisterNoisyAudioReceiver()
+ }
+
+ private fun registerNoisyAudioReceiver() {
+ context.registerReceiver(noisyAudioReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
+ isNoisyAudioReceiverRegistered = true
+ }
+
+ private fun unregisterNoisyAudioReceiver() {
if (isNoisyAudioReceiverRegistered) {
context.unregisterReceiver(noisyAudioReceiver)
isNoisyAudioReceiverRegistered = false
@@ -51,14 +60,17 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
private suspend fun updateSession(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument("uri")?.let { Uri.parse(it) }
- val title = call.argument("title")
+ val title = call.argument("title") ?: uri?.toString()
val durationMillis = call.argument("durationMillis")?.toLong()
val stateString = call.argument("state")
val positionMillis = call.argument("positionMillis")?.toLong()
val playbackSpeed = call.argument("playbackSpeed")?.toFloat()
if (uri == null || title == null || durationMillis == null || stateString == null || positionMillis == null || playbackSpeed == null) {
- result.error("update-args", "missing arguments", null)
+ result.error(
+ "updateSession-args", "missing arguments: uri=$uri, title=$title, durationMillis=$durationMillis" +
+ ", stateString=$stateString, positionMillis=$positionMillis, playbackSpeed=$playbackSpeed", null
+ )
return
}
@@ -67,7 +79,7 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
STATE_PAUSED -> PlaybackStateCompat.STATE_PAUSED
STATE_PLAYING -> PlaybackStateCompat.STATE_PLAYING
else -> {
- result.error("update-state", "unknown state=$stateString", null)
+ result.error("updateSession-state", "unknown state=$stateString", null)
return
}
}
@@ -90,39 +102,41 @@ class MediaSessionHandler(private val context: Context, private val mediaCommand
.build()
FlutterUtils.runOnUiThread {
- if (session == null) {
- val mbrIntent = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
- val mbrName = ComponentName(context, MediaButtonReceiver::class.java)
- session = MediaSessionCompat(context, "aves", mbrName, mbrIntent).apply {
- setCallback(mediaCommandHandler)
+ try {
+ if (session == null) {
+ val mbrIntent = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
+ val mbrName = ComponentName(context, MediaButtonReceiver::class.java)
+ session = MediaSessionCompat(context, "aves", mbrName, mbrIntent).apply {
+ setCallback(mediaCommandHandler)
+ }
}
- }
- session!!.apply {
- val metadata = MediaMetadataCompat.Builder()
- .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
- .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
- .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMillis)
- .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri.toString())
- .build()
- setMetadata(metadata)
- setPlaybackState(playbackState)
- if (!isActive) {
- isActive = true
+ session!!.apply {
+ val metadata = MediaMetadataCompat.Builder()
+ .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
+ .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
+ .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, durationMillis)
+ .putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, uri.toString())
+ .build()
+ setMetadata(metadata)
+ setPlaybackState(playbackState)
+ if (!isActive) {
+ isActive = true
+ }
}
- }
- val isPlaying = state == PlaybackStateCompat.STATE_PLAYING
- if (!wasPlaying && isPlaying) {
- context.registerReceiver(noisyAudioReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
- isNoisyAudioReceiverRegistered = true
- } else if (wasPlaying && !isPlaying) {
- context.unregisterReceiver(noisyAudioReceiver)
- isNoisyAudioReceiverRegistered = false
+ val isPlaying = state == PlaybackStateCompat.STATE_PLAYING
+ if (!wasPlaying && isPlaying) {
+ registerNoisyAudioReceiver()
+ } else if (wasPlaying && !isPlaying) {
+ unregisterNoisyAudioReceiver()
+ }
+ wasPlaying = isPlaying
+
+ result.success(null)
+ } catch (e: Exception) {
+ result.error("updateSession-exception", e.message, e.stackTraceToString())
}
- wasPlaying = isPlaying
}
-
- result.success(null)
}
private fun releaseSession(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
index bb349855a..f8915a8f6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataEditHandler.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
+import java.io.FileNotFoundException
class MetadataEditHandler(private val contextWrapper: ContextWrapper) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -195,6 +196,8 @@ private class MetadataOpCallback(
} else {
"$errorCodeBase-mp4largeother"
}
+ } else if (throwable is FileNotFoundException) {
+ "$errorCodeBase-filenotfound"
} else {
"$errorCodeBase-failure"
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
index 291de3940..1358fdb69 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataFetchHandler.kt
@@ -67,7 +67,7 @@ import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeRational
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeString
import deckers.thibault.aves.metadata.metadataextractor.Helper.isPngTextDir
import deckers.thibault.aves.model.FieldMap
-import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
+import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.TIFF_EXTENSION_PATTERN
@@ -104,8 +104,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
"getPanoramaInfo" -> ioScope.launch { safe(call, result, ::getPanoramaInfo) }
"getIptc" -> ioScope.launch { safe(call, result, ::getIptc) }
"getXmp" -> ioScope.launch { safe(call, result, ::getXmp) }
- "hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentResolverProp) }
- "getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentResolverProp) }
+ "hasContentResolverProp" -> ioScope.launch { safe(call, result, ::hasContentProp) }
+ "getContentResolverProp" -> ioScope.launch { safe(call, result, ::getContentPropValue) }
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
"getDescription" -> ioScope.launch { safe(call, result, ::getDescription) }
else -> result.notImplemented()
@@ -1047,10 +1047,10 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
result.success(xmpStrings)
}
- private fun hasContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
+ private fun hasContentProp(call: MethodCall, result: MethodChannel.Result) {
val prop = call.argument("prop")
if (prop == null) {
- result.error("hasContentResolverProp-args", "missing arguments", null)
+ result.error("hasContentProp-args", "missing arguments", null)
return
}
@@ -1058,27 +1058,27 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
when (prop) {
"owner_package_name" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
else -> {
- result.error("hasContentResolverProp-unknown", "unknown property=$prop", null)
+ result.error("hasContentProp-unknown", "unknown property=$prop", null)
return
}
}
)
}
- private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
+ private fun getContentPropValue(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument("mimeType")
val uri = call.argument("uri")?.let { Uri.parse(it) }
val prop = call.argument("prop")
if (mimeType == null || uri == null || prop == null) {
- result.error("getContentResolverProp-args", "missing arguments", null)
+ result.error("getContentPropValue-args", "missing arguments", null)
return
}
try {
- val value = context.queryContentResolverProp(uri, mimeType, prop)
+ val value = context.queryContentPropValue(uri, mimeType, prop)
result.success(value?.toString())
} catch (e: Exception) {
- result.error("getContentResolverProp-query", "failed to query prop for uri=$uri", e.message)
+ result.error("getContentPropValue-query", "failed to query prop for uri=$uri", e.message)
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt
new file mode 100644
index 000000000..05a6ba398
--- /dev/null
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt
@@ -0,0 +1,79 @@
+package deckers.thibault.aves.channel.calls
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler
+
+class SecurityHandler(private val context: Context) : MethodCallHandler {
+ private var sharedPreferences: SharedPreferences? = null
+
+ override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
+ when (call.method) {
+ "writeValue" -> safe(call, result, ::writeValue)
+ "readValue" -> safe(call, result, ::readValue)
+ else -> result.notImplemented()
+ }
+ }
+
+ private fun getStore(): SharedPreferences {
+ if (sharedPreferences == null) {
+ val mainKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ sharedPreferences = EncryptedSharedPreferences.create(
+ context,
+ FILENAME,
+ mainKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+ return sharedPreferences!!
+ }
+
+ private fun writeValue(call: MethodCall, result: MethodChannel.Result) {
+ val key = call.argument("key")
+ val value = call.argument("value")
+ if (key == null) {
+ result.error("writeValue-args", "missing arguments", null)
+ return
+ }
+
+ with(getStore().edit()) {
+ when (value) {
+ is Boolean -> putBoolean(key, value)
+ is Float -> putFloat(key, value)
+ is Int -> putInt(key, value)
+ is Long -> putLong(key, value)
+ is String -> putString(key, value)
+ null -> remove(key)
+ else -> {
+ result.error("writeValue-type", "unsupported type for value=$value", null)
+ return
+ }
+ }
+ apply()
+ }
+ result.success(true)
+ }
+
+ private fun readValue(call: MethodCall, result: MethodChannel.Result) {
+ val key = call.argument("key")
+ if (key == null) {
+ result.error("readValue-args", "missing arguments", null)
+ return
+ }
+
+ result.success(getStore().all[key])
+ }
+
+ companion object {
+ const val CHANNEL = "deckers.thibault/aves/security"
+ const val FILENAME = "secret_shared_prefs"
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt
index 435705285..1dcc4bd32 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt
@@ -8,6 +8,7 @@ import androidx.core.os.EnvironmentCompat
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.PermissionManager
+import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
@@ -25,6 +26,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
+ "getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
@@ -88,6 +90,10 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
result.success(volumes)
}
+ private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
+ result.success(StorageUtils.getVaultRoot(context))
+ }
+
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
val path = call.argument("path")
if (path == null) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt
index 3086708ad..752633094 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt
@@ -109,7 +109,7 @@ class ThumbnailFetcher internal constructor(
} else {
@Suppress("deprecation")
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
- // from Android 10, returned thumbnail is already rotated according to EXIF orientation
+ // from Android 10 (API 29), returned thumbnail is already rotated according to EXIF orientation
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt
index 258682b00..1f6590dd6 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt
@@ -12,7 +12,7 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
result.success(true)
}
- override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
+ private fun setWindowFlag(call: MethodCall, result: MethodChannel.Result, flag: Int) {
val on = call.argument("on")
if (on == null) {
result.error("keepOn-args", "missing arguments", null)
@@ -20,8 +20,6 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
}
val window = activity.window
- val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
-
val old = (window.attributes.flags and flag) != 0
if (old != on) {
if (on) {
@@ -33,6 +31,14 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
result.success(null)
}
+ override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
+ setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ }
+
+ override fun secureScreen(call: MethodCall, result: MethodChannel.Result) {
+ setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_SECURE)
+ }
+
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
val orientation = call.argument("orientation")
if (orientation == null) {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt
index 46d1e43b8..3a50fd324 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt
@@ -13,6 +13,10 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
result.success(null)
}
+ override fun secureScreen(call: MethodCall, result: MethodChannel.Result) {
+ result.success(null)
+ }
+
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
result.success(false)
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt
index 0a6f41249..492c6deeb 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt
@@ -13,6 +13,7 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
when (call.method) {
"isActivity" -> Coresult.safe(call, result, ::isActivity)
"keepScreenOn" -> Coresult.safe(call, result, ::keepScreenOn)
+ "secureScreen" -> Coresult.safe(call, result, ::secureScreen)
"isRotationLocked" -> Coresult.safe(call, result, ::isRotationLocked)
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
@@ -25,6 +26,8 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
abstract fun keepScreenOn(call: MethodCall, result: MethodChannel.Result)
+ abstract fun secureScreen(call: MethodCall, result: MethodChannel.Result)
+
private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
var locked = false
try {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt
index 0e149942b..de0d7d78a 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt
@@ -139,8 +139,8 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String?
- val width = arguments["width"] as Int?
- val height = arguments["height"] as Int?
+ val width = (arguments["width"] as Number?)?.toInt()
+ val height = (arguments["height"] as Number?)?.toInt()
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) {
error("export-args", "missing arguments", null)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
index 74a9a9a3d..f618c0df1 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt
@@ -15,7 +15,7 @@ import deckers.thibault.aves.metadata.Mp4ParserHelper.processBoxes
import deckers.thibault.aves.metadata.Mp4ParserHelper.toBytes
import deckers.thibault.aves.metadata.metadataextractor.SafeMp4UuidBoxHandler
import deckers.thibault.aves.metadata.metadataextractor.SafeXmpReader
-import deckers.thibault.aves.utils.ContextUtils.queryContentResolverProp
+import deckers.thibault.aves.utils.ContextUtils.queryContentPropValue
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MemoryUtils
import deckers.thibault.aves.utils.MimeTypes
@@ -130,7 +130,7 @@ object XMP {
) {
if (MimeTypes.isHeic(mimeType) && !foundXmp && StorageUtils.isMediaStoreContentUri(uri) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
- val xmpBytes = context.queryContentResolverProp(uri, mimeType, MediaStore.MediaColumns.XMP)
+ val xmpBytes = context.queryContentPropValue(uri, mimeType, MediaStore.MediaColumns.XMP)
if (xmpBytes is ByteArray && xmpBytes.size > 0) {
val xmpMeta = XMPMetaFactory.parseFromBuffer(xmpBytes, SafeXmpReader.PARSE_OPTIONS)
processXmp(xmpMeta)
@@ -170,6 +170,11 @@ object XMP {
}
}
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
+
+ // TODO TLAD [mp4] `IsoFile` init may fail if a skipped box has a `org.mp4parser.boxes.iso14496.part12.MetaBox` as parent,
+ // because `MetaBox.parse()` changes the argument `dataSource` to a `RewindableReadableByteChannel`,
+ // so it is no longer a seekable `FileChannel`, which is a requirement to skip boxes.
+
IsoFile(channel, boxParser).use { isoFile ->
isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
val boxSize = box.size
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
index 9d5749561..e858c03e4 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt
@@ -33,6 +33,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
class SourceEntry {
+ private val origin: Int
val uri: Uri // content or file URI
var path: String? = null // best effort to get local path
private val sourceMimeType: String
@@ -48,12 +49,14 @@ class SourceEntry {
private var foundExif: Boolean = false
- constructor(uri: Uri, sourceMimeType: String) {
+ constructor(origin: Int, uri: Uri, sourceMimeType: String) {
+ this.origin = origin
this.uri = uri
this.sourceMimeType = sourceMimeType
}
constructor(map: FieldMap) {
+ origin = map["origin"] as Int
uri = Uri.parse(map["uri"] as String)
path = map["path"] as String?
sourceMimeType = map["sourceMimeType"] as String
@@ -77,6 +80,7 @@ class SourceEntry {
fun toMap(): FieldMap {
return hashMapOf(
+ "origin" to origin,
"uri" to uri.toString(),
"path" to path,
"sourceMimeType" to sourceMimeType,
@@ -249,13 +253,15 @@ class SourceEntry {
private fun fillByTiffDecode(context: Context) {
try {
- val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return
- val options = TiffBitmapFactory.Options().apply {
- inJustDecodeBounds = true
+ context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
+ val fd = pfd.detachFd()
+ val options = TiffBitmapFactory.Options().apply {
+ inJustDecodeBounds = true
+ }
+ TiffBitmapFactory.decodeFileDescriptor(fd, options)
+ width = options.outWidth
+ height = options.outHeight
}
- TiffBitmapFactory.decodeFileDescriptor(fd, options)
- width = options.outWidth
- height = options.outHeight
} catch (e: Exception) {
// ignore
}
@@ -267,5 +273,11 @@ class SourceEntry {
is Int -> o.toLong()
else -> o as? Long
}
+
+ // should match `EntryOrigins` on the Dart side
+ const val ORIGIN_MEDIA_STORE_CONTENT = 0
+ const val ORIGIN_UNKNOWN_CONTENT = 1
+ const val ORIGIN_FILE = 2
+ const val ORIGIN_VAULT = 3
}
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt
index 7ff773d21..368035721 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt
@@ -44,6 +44,7 @@ internal class ContentImageProvider : ImageProvider() {
}
val fields: FieldMap = hashMapOf(
+ "origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
"uri" to uri.toString(),
"sourceMimeType" to mimeType,
)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt
index 3c3a624d5..6a1b0725e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.ContextWrapper
import android.net.Uri
import android.util.Log
+import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
import java.io.File
@@ -15,7 +16,7 @@ internal class FileImageProvider : ImageProvider() {
return
}
- val entry = SourceEntry(uri, sourceMimeType)
+ val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType)
val path = uri.path
if (path != null) {
@@ -52,6 +53,19 @@ internal class FileImageProvider : ImageProvider() {
throw Exception("failed to delete entry with uri=$uri path=$path")
}
+ override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
+ try {
+ val file = File(path)
+ if (file.exists()) {
+ newFields["dateModifiedSecs"] = file.lastModified() / 1000
+ newFields["sizeBytes"] = file.length()
+ }
+ callback.onSuccess(newFields)
+ } catch (e: SecurityException) {
+ callback.onFailure(e)
+ }
+ }
+
companion object {
private val LOG_TAG = LogUtils.createTag()
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index 9e2e9c9eb..4fb59c16c 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -811,11 +811,6 @@ abstract class ImageProvider {
fields: List,
callback: ImageOpCallback,
) {
- if (dateMillis != null && dateMillis < 0) {
- callback.onFailure(Exception("dateMillis=$dateMillis cannot be negative"))
- return
- }
-
val success = editExif(context, path, uri, mimeType, callback) { exif ->
when {
dateMillis != null -> {
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
index 3b29bcf55..5e568be50 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt
@@ -9,6 +9,7 @@ import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
+import androidx.core.net.toUri
import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
@@ -30,6 +31,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.io.File
import java.io.OutputStream
+import java.io.SyncFailedException
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.coroutines.Continuation
@@ -219,6 +221,7 @@ class MediaStoreImageProvider : ImageProvider() {
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
} else {
var entryMap: FieldMap = hashMapOf(
+ "origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
"uri" to itemUri.toString(),
"path" to cursor.getString(pathColumn),
"sourceMimeType" to mimeType,
@@ -349,7 +352,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
} catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory,
- // the delete request may yield a `RecoverableSecurityException` on Android >=10
+ // the delete request may yield a `RecoverableSecurityException` on API >=29
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && contextWrapper is Activity) {
Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException)
@@ -386,10 +389,12 @@ class MediaStoreImageProvider : ImageProvider() {
val entries = kv.value
val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER
+ val toVault = StorageUtils.isInVault(activity, targetDir)
+ val toAppDir = toBin || toVault
var effectiveTargetDir: String? = null
var targetDirDocFile: DocumentFileCompat? = null
- if (!toBin) {
+ if (!toAppDir) {
effectiveTargetDir = targetDir
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (!File(targetDir).exists()) {
@@ -437,13 +442,20 @@ class MediaStoreImageProvider : ImageProvider() {
// - there is no documentation regarding support for usage with removable storage
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
try {
- if (toBin) {
- val trashDir = StorageUtils.trashDirFor(activity, sourcePath)
- if (trashDir != null) {
- effectiveTargetDir = ensureTrailingSeparator(trashDir.path)
- targetDirDocFile = DocumentFileCompat.fromFile(trashDir)
+ val appDir = when {
+ toBin -> StorageUtils.trashDirFor(activity, sourcePath)
+ toVault -> File(targetDir)
+ else -> null
+ }
+ if (appDir != null) {
+ effectiveTargetDir = ensureTrailingSeparator(appDir.path)
+ targetDirDocFile = DocumentFileCompat.fromFile(appDir)
+
+ if (toVault) {
+ appDir.mkdirs()
}
}
+
if (effectiveTargetDir != null) {
val newFields = if (isCancelledOp()) skippedFieldMap else {
val sourceFile = File(sourcePath)
@@ -462,6 +474,7 @@ class MediaStoreImageProvider : ImageProvider() {
mimeType = mimeType,
copy = copy,
toBin = toBin,
+ toVault = toVault,
)
}
}
@@ -488,6 +501,7 @@ class MediaStoreImageProvider : ImageProvider() {
mimeType: String,
copy: Boolean,
toBin: Boolean,
+ toVault: Boolean,
): FieldMap {
val sourcePath = sourceFile.path
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
@@ -512,7 +526,16 @@ class MediaStoreImageProvider : ImageProvider() {
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
targetNameWithoutExtension = targetNameWithoutExtension,
- ) { output: OutputStream -> sourceDocFile.copyTo(output) }
+ ) { output: OutputStream ->
+ try {
+ sourceDocFile.copyTo(output)
+ } catch (e: SyncFailedException) {
+ // The copied file is synced after writing, but it consistently fails in some cases
+ // (e.g. copying to SD card on Xiaomi 2201117PG with Android 11).
+ // It seems this failure can be safely ignored, as the new file is complete.
+ Log.w(LOG_TAG, "sync failure after copying from uri=$sourceUri, path=$sourcePath to targetDir=$targetDir", e)
+ }
+ }
if (!copy) {
// delete original entry
@@ -522,13 +545,21 @@ class MediaStoreImageProvider : ImageProvider() {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
- if (toBin) {
- return hashMapOf(
+ return if (toBin) {
+ hashMapOf(
"trashed" to true,
"trashPath" to targetPath,
)
+ } else if (toVault) {
+ hashMapOf(
+ "uri" to File(targetPath).toUri().toString(),
+ "contentId" to null,
+ "path" to targetPath,
+ "origin" to SourceEntry.ORIGIN_VAULT,
+ )
+ } else {
+ scanNewPath(activity, targetPath, mimeType)
}
- return scanNewPath(activity, targetPath, mimeType)
}
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
@@ -910,7 +941,7 @@ class MediaStoreImageProvider : ImageProvider() {
private val VIDEO_PROJECTION = arrayOf(
*BASE_PROJECTION,
MediaColumns.DURATION,
- // `ORIENTATION` was only available for images before Android 10
+ // `ORIENTATION` was only available for images before Android 10 (API 29)
*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(
MediaStore.MediaColumns.ORIENTATION,
) else emptyArray()
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt
index a0683f388..df443830d 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt
@@ -7,6 +7,7 @@ import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
+import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.Log
import deckers.thibault.aves.utils.UriUtils.tryParseId
@@ -30,7 +31,13 @@ object ContextUtils {
return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name }
}
- fun Context.queryContentResolverProp(uri: Uri, mimeType: String, prop: String): Any? {
+ // `flag`: `DocumentsContract.Document.FLAG_SUPPORTS_COPY`, etc.
+ fun Context.queryDocumentProviderFlag(docUri: Uri, flag: Int): Boolean {
+ val flags = queryContentPropValue(docUri, "", DocumentsContract.Document.COLUMN_FLAGS) as Long?
+ return if (flags != null) (flags.toInt() and flag) == flag else false
+ }
+
+ fun Context.queryContentPropValue(uri: Uri, mimeType: String, column: String): Any? {
var contentUri: Uri = uri
if (StorageUtils.isMediaStoreContentUri(uri)) {
uri.tryParseId()?.let { id ->
@@ -43,26 +50,26 @@ object ContextUtils {
}
}
- // throws SQLiteException when the requested prop is not a known column
- val cursor = contentResolver.query(contentUri, arrayOf(prop), null, null, null)
- if (cursor == null || !cursor.moveToFirst()) {
- throw Exception("failed to get cursor for contentUri=$contentUri")
- }
-
var value: Any? = null
try {
- value = when (cursor.getType(0)) {
- Cursor.FIELD_TYPE_NULL -> null
- Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
- Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
- Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
- Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
- else -> null
+ val cursor = contentResolver.query(contentUri, arrayOf(column), null, null, null)
+ if (cursor == null || !cursor.moveToFirst()) {
+ Log.w(LOG_TAG, "failed to get cursor for contentUri=$contentUri column=$column")
+ } else {
+ value = when (cursor.getType(0)) {
+ Cursor.FIELD_TYPE_NULL -> null
+ Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0)
+ Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0)
+ Cursor.FIELD_TYPE_STRING -> cursor.getString(0)
+ Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0)
+ else -> null
+ }
+ cursor.close()
}
} catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri key=$prop", e)
+ // throws SQLiteException/IllegalArgumentException when the requested prop is not a known column
+ Log.w(LOG_TAG, "failed to get value for contentUri=$contentUri column=$column", e)
}
- cursor.close()
return value
}
}
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
index df4ea5f75..1215d7ab7 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt
@@ -1,5 +1,6 @@
package deckers.thibault.aves.utils
+import android.webkit.MimeTypeMap
import androidx.exifinterface.media.ExifInterface
object MimeTypes {
@@ -153,47 +154,11 @@ object MimeTypes {
// among other refs:
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
fun extensionFor(mimeType: String): String? = when (mimeType) {
- ARW -> ".arw"
AVI, AVI_VND -> ".avi"
- AVIF -> ".avif"
- BMP -> ".bmp"
- CR2 -> ".cr2"
- CRW -> ".crw"
- DCR -> ".dcr"
- DJVU -> ".djvu"
- DNG -> ".dng"
- ERF -> ".erf"
- GIF -> ".gif"
HEIC, HEIF -> ".heif"
- ICO -> ".ico"
- JPEG -> ".jpg"
- K25 -> ".k25"
- KDC -> ".kdc"
- MKV -> ".mkv"
- MOV -> ".mov"
MP2T, MP2TS -> ".m2ts"
- MP4 -> ".mp4"
- MRW -> ".mrw"
- NEF -> ".nef"
- NRW -> ".nrw"
- OGV -> ".ogv"
- ORF -> ".orf"
- PEF -> ".pef"
- PNG -> ".png"
PSD_VND, PSD_X -> ".psd"
- RAF -> ".raf"
- RAW -> ".raw"
- RW2 -> ".rw2"
- SR2 -> ".sr2"
- SRF -> ".srf"
- SRW -> ".srw"
- SVG -> ".svg"
- TIFF -> ".tiff"
- WBMP -> ".wbmp"
- WEBM -> ".webm"
- WEBP -> ".webp"
- X3F -> ".x3f"
- else -> null
+ else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
}
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
index 08355a5de..2bad51da4 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt
@@ -119,7 +119,7 @@ object PermissionManager {
dirSet.add("")
}
} else {
- // request volume root until Android 10
+ // request volume root until Android 10 (API 29)
dirSet.add("")
}
dirsPerVolume[volumePath] = dirSet
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
index 078fda691..457a36e0e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt
@@ -39,28 +39,29 @@ object StorageUtils {
private const val TREE_URI_ROOT = "content://$EXTERNAL_STORAGE_PROVIDER_AUTHORITY/tree/"
+ private val UUID_PATTERN = Regex("[A-Fa-f\\d-]+")
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
const val TRASH_PATH_PLACEHOLDER = "#trash"
private fun isAppFile(context: Context, path: String): Boolean {
- val filesDirs = context.getExternalFilesDirs(null).filterNotNull()
- return filesDirs.any { path.startsWith(it.path) }
+ val dirs = context.getExternalFilesDirs(null).filterNotNull()
+ return dirs.any { path.startsWith(it.path) }
}
private fun appExternalFilesDirFor(context: Context, path: String): File? {
- val filesDirs = context.getExternalFilesDirs(null).filterNotNull()
+ val dirs = context.getExternalFilesDirs(null).filterNotNull()
val volumePath = getVolumePath(context, path)
- return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.firstOrNull()
+ return volumePath?.let { dirs.firstOrNull { it.startsWith(volumePath) } } ?: dirs.firstOrNull()
}
fun trashDirFor(context: Context, path: String): File? {
- val filesDir = appExternalFilesDirFor(context, path)
- if (filesDir == null) {
+ val externalFilesDir = appExternalFilesDirFor(context, path)
+ if (externalFilesDir == null) {
Log.e(LOG_TAG, "failed to find external files dir for path=$path")
return null
}
- val trashDir = File(filesDir, "trash")
+ val trashDir = File(externalFilesDir, "trash")
if (!trashDir.exists() && !trashDir.mkdirs()) {
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
return null
@@ -68,6 +69,10 @@ object StorageUtils {
return trashDir
}
+ fun getVaultRoot(context: Context) = ensureTrailingSeparator(File(context.filesDir, "vault").path)
+
+ fun isInVault(context: Context, path: String) = path.startsWith(getVaultRoot(context))
+
/**
* Volume paths
*/
@@ -259,6 +264,7 @@ object StorageUtils {
// e.g.
// /storage/emulated/0/ -> primary
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
+ // /storage/extSdCard/ -> 1234-5678 [Android 5.1.1, Samsung Galaxy Core Prime]
private fun getVolumeUuidForDocumentUri(context: Context, anyPath: String): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
@@ -278,7 +284,22 @@ object StorageUtils {
return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID
}
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
- return uuid.uppercase(Locale.ROOT)
+ if (uuid.matches(UUID_PATTERN)) {
+ return uuid.uppercase(Locale.ROOT)
+ }
+ }
+
+ // fallback when UUID does not appear in the SD card volume path
+ context.contentResolver.persistedUriPermissions.firstOrNull { uriPermission ->
+ convertTreeDocumentUriToDirPath(context, uriPermission.uri)?.let {
+ getVolumePath(context, it)?.let { grantedVolumePath ->
+ grantedVolumePath == volumePath
+ }
+ } ?: false
+ }?.let { uriPermission ->
+ splitTreeDocumentUri(uriPermission.uri)?.let { (uuid, _) ->
+ return uuid
+ }
}
}
@@ -289,6 +310,7 @@ object StorageUtils {
// e.g.
// primary -> /storage/emulated/0/
// 10F9-3F13 -> /storage/10F9-3F13/
+ // 1234-5678 -> /storage/extSdCard/ [Android 5.1.1, Samsung Galaxy Core Prime]
private fun getVolumePathFromTreeDocumentUriUuid(context: Context, uuid: String): String? {
if (uuid == EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID) {
return getPrimaryVolumePath(context)
@@ -318,6 +340,10 @@ object StorageUtils {
}
}
+ // fallback when UUID does not appear in the SD card volume path
+ val primaryVolumePath = getPrimaryVolumePath(context)
+ getVolumePaths(context).firstOrNull { it != primaryVolumePath }?.let { return it }
+
Log.e(LOG_TAG, "failed to find volume path for UUID=$uuid")
return null
}
@@ -350,9 +376,9 @@ object StorageUtils {
}
// e.g.
- // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
- // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
- fun convertTreeDocumentUriToDirPath(context: Context, treeDocumentUri: Uri): String? {
+ // content://com.android.externalstorage.documents/tree/primary%3A -> ("primary", "")
+ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> ("10F9-3F13", "Pictures")
+ private fun splitTreeDocumentUri(treeDocumentUri: Uri): Pair? {
val treeDocumentUriString = treeDocumentUri.toString()
if (treeDocumentUriString.length <= TREE_URI_ROOT.length) return null
val encoded = treeDocumentUriString.substring(TREE_URI_ROOT.length)
@@ -362,13 +388,24 @@ object StorageUtils {
val uuid = group(1)
val relativePath = group(2)
if (uuid != null && relativePath != null) {
- val volumePath = getVolumePathFromTreeDocumentUriUuid(context, uuid)
- if (volumePath != null) {
- return ensureTrailingSeparator(volumePath + relativePath)
- }
+ return Pair(uuid, relativePath)
}
}
}
+ Log.e(LOG_TAG, "failed to split treeDocumentUri=$treeDocumentUri to UUID and relative path")
+ return null
+ }
+
+ // e.g.
+ // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
+ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
+ fun convertTreeDocumentUriToDirPath(context: Context, treeDocumentUri: Uri): String? {
+ splitTreeDocumentUri(treeDocumentUri)?.let { (uuid, relativePath) ->
+ val volumePath = getVolumePathFromTreeDocumentUriUuid(context, uuid)
+ if (volumePath != null) {
+ return ensureTrailingSeparator(volumePath + relativePath)
+ }
+ }
Log.e(LOG_TAG, "failed to convert treeDocumentUri=$treeDocumentUri to path")
return null
}
@@ -512,7 +549,7 @@ object StorageUtils {
}
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
- // to work around a bug from Android 10 where metadata redaction corrupts HEIC images.
+ // to work around a bug from Android 10 (API 29) where metadata redaction corrupts HEIC images.
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some non image/video content URIs (e.g. `downloads`, `file`)
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long? = null): Uri {
diff --git a/android/app/src/main/res/values-eu/strings.xml b/android/app/src/main/res/values-eu/strings.xml
new file mode 100644
index 000000000..ca50b6ea6
--- /dev/null
+++ b/android/app/src/main/res/values-eu/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Bilatu
+ Bideoak
+ Argazki-markoa
+ Irudiak eta bideoak eskaneatu
+ Horma-papera
+ Media eskaneatu
+ Gelditu
+ Media eskaneatzen
+ Aves
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values-sk/strings.xml b/android/app/src/main/res/values-sk/strings.xml
new file mode 100644
index 000000000..08fd7dbe9
--- /dev/null
+++ b/android/app/src/main/res/values-sk/strings.xml
@@ -0,0 +1,12 @@
+
+
+ Hľadať
+ Aves
+ Rám fotky
+ Tapeta
+ Videá
+ Zastaviť
+ Skenovanie médií
+ Skenovanie obrázkov & videí
+ Skenovanie média
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 000000000..9f7329404
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/full_backup_content.xml b/android/app/src/main/res/xml/full_backup_content.xml
new file mode 100644
index 000000000..5e5ca09e7
--- /dev/null
+++ b/android/app/src/main/res/xml/full_backup_content.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml
index bdf6728a3..168574e97 100644
--- a/android/app/src/main/res/xml/provider_paths.xml
+++ b/android/app/src/main/res/xml/provider_paths.xml
@@ -1,7 +1,15 @@
+
+
+
+
+
+
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index e0e447a3d..3a528264c 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
diff --git a/assets/terms.md b/assets/terms.md
index 50e0dc078..dee3479b2 100644
--- a/assets/terms.md
+++ b/assets/terms.md
@@ -2,7 +2,7 @@
“Aves Gallery” is an open-source gallery and metadata explorer app allowing you to access and manage your local photos and videos.
-You must use the app for legal, authorized and acceptable purposes.
+The app is designed for legal, authorized and acceptable purposes.
## Disclaimer
diff --git a/fastlane/metadata/android/en-US/changelogs/91.txt b/fastlane/metadata/android/en-US/changelogs/91.txt
new file mode 100644
index 000000000..d1e8ac1c4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/91.txt
@@ -0,0 +1,5 @@
+In v1.8.0:
+- Android TV support (cont'd)
+- hide your secrets in vaults
+- enjoy the app in Basque
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/fastlane/metadata/android/en-US/changelogs/9101.txt b/fastlane/metadata/android/en-US/changelogs/9101.txt
new file mode 100644
index 000000000..d1e8ac1c4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/9101.txt
@@ -0,0 +1,5 @@
+In v1.8.0:
+- Android TV support (cont'd)
+- hide your secrets in vaults
+- enjoy the app in Basque
+Full changelog available on GitHub
\ No newline at end of file
diff --git a/fastlane/metadata/android/eu/full_description.txt b/fastlane/metadata/android/eu/full_description.txt
new file mode 100644
index 000000000..b0d5229c3
--- /dev/null
+++ b/fastlane/metadata/android/eu/full_description.txt
@@ -0,0 +1,5 @@
+Aves aplikazioak mota guztitako irudi eta bideoak, nahiz ohiko zure JPEG eta MP4 fitxategiak eta exotikoagoak diren orri ugaritako TIFF, SVG, AVI zaharrak eta are gehiago maneiatzen ditu! Zure media-bilduma eskaneatzen du mugimendu-argazkiak,panoramikak (argazki esferikoak bezala ere ezagunak), 360°-ko bideoak, baita GeoTIFF fitxategiak ere.
+
+Nabigazioa eta bilaketa Aves aplikazioaren zati garrantzitsu bat da. Helburua, erabiltzaileek albumetatik argazkietara, etiketetara, mapetara, etab. modu errazean mugi ahal izatea da.
+
+Aves Androidera (KitKatetik Android 13ra, Android TV barne) egiten da ezaugarri ugarirekin: widgetak, aplikazioko lasterbideak, pantaila-babeslea eta bilaketa globala. Baita ere, media-bisore edo -hautagailu bezala ere erabil daiteke.
\ No newline at end of file
diff --git a/fastlane/metadata/android/eu/images/featureGraphic.png b/fastlane/metadata/android/eu/images/featureGraphic.png
new file mode 100644
index 000000000..ad29dfe39
Binary files /dev/null and b/fastlane/metadata/android/eu/images/featureGraphic.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/1.png b/fastlane/metadata/android/eu/images/phoneScreenshots/1.png
new file mode 100644
index 000000000..7f2dca48e
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/1.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/2.png b/fastlane/metadata/android/eu/images/phoneScreenshots/2.png
new file mode 100644
index 000000000..be2d319a7
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/2.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/3.png b/fastlane/metadata/android/eu/images/phoneScreenshots/3.png
new file mode 100644
index 000000000..98fa51b08
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/3.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/4.png b/fastlane/metadata/android/eu/images/phoneScreenshots/4.png
new file mode 100644
index 000000000..f2a53a498
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/4.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/5.png b/fastlane/metadata/android/eu/images/phoneScreenshots/5.png
new file mode 100644
index 000000000..244028de4
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/5.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/6.png b/fastlane/metadata/android/eu/images/phoneScreenshots/6.png
new file mode 100644
index 000000000..41a3c9e5b
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/6.png differ
diff --git a/fastlane/metadata/android/eu/images/phoneScreenshots/7.png b/fastlane/metadata/android/eu/images/phoneScreenshots/7.png
new file mode 100644
index 000000000..3d99af3ed
Binary files /dev/null and b/fastlane/metadata/android/eu/images/phoneScreenshots/7.png differ
diff --git a/fastlane/metadata/android/eu/short_description.txt b/fastlane/metadata/android/eu/short_description.txt
new file mode 100644
index 000000000..b2d28aa2e
--- /dev/null
+++ b/fastlane/metadata/android/eu/short_description.txt
@@ -0,0 +1 @@
+Galeria eta metadatuen nabigatzailea
\ No newline at end of file
diff --git a/fastlane/metadata/android/sk/full_description.txt b/fastlane/metadata/android/sk/full_description.txt
new file mode 100644
index 000000000..74557d170
--- /dev/null
+++ b/fastlane/metadata/android/sk/full_description.txt
@@ -0,0 +1,5 @@
+Aves aplikácia je schopná si poradiť s veľkým množstvom rôznych formátov obrázkov a videí, ako napríklad typickými JPEG a MP4, ale aj s exotickejšími TIFF, SVG, AVI a mnoho iných! Aplikácia skenuje média v zariadení a identifikje fotky v pohybe, panorámy , 360° videá, ako aj GeoTIFF súbory.
+
+Navigácia a vyhľadávanie je dôležitou súčasťou aplikácie Aves. Jej cieľom je poskytnúť užívateľom jednoduchý prechod z albumov, do fotiek, tagov, máp, atď.
+
+Aves je schopný pracovať s Android (od KitKat do Android 13, včetne Android TV) a ponúka rozšírenia ako miniaplikácie (widgety), skratky aplikácie, šetrič obrazovky a globálne vyhľadávanie. Rovnako poskytuje prehľadávnie médií.
\ No newline at end of file
diff --git a/fastlane/metadata/android/sk/short_description.txt b/fastlane/metadata/android/sk/short_description.txt
new file mode 100644
index 000000000..fa44ffa8c
--- /dev/null
+++ b/fastlane/metadata/android/sk/short_description.txt
@@ -0,0 +1 @@
+Prehliadač galérie a metadát
\ No newline at end of file
diff --git a/lib/app_mode.dart b/lib/app_mode.dart
index de3cd6455..925c3d967 100644
--- a/lib/app_mode.dart
+++ b/lib/app_mode.dart
@@ -26,6 +26,11 @@ extension ExtraAppMode on AppMode {
bool get canSelectFilter => this == AppMode.main;
+ bool get canCreateFilter => {
+ AppMode.main,
+ AppMode.pickFilterInternal,
+ }.contains(this);
+
bool get isPickingMedia => {
AppMode.pickSingleMediaExternal,
AppMode.pickMultipleMediaExternal,
diff --git a/lib/geo/countries.dart b/lib/geo/countries.dart
index 8f1e24025..f2b0c32b6 100644
--- a/lib/geo/countries.dart
+++ b/lib/geo/countries.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:isolate';
import 'package:aves/geo/topojson.dart';
import 'package:collection/collection.dart';
@@ -48,26 +49,22 @@ class CountryTopology {
final topology = await getTopology();
if (topology == null) return null;
- return compute<_IsoNumericCodeMapData, Map>>(_isoNumericCodeMap, _IsoNumericCodeMapData(topology, positions));
- }
-
- static Future