Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2022-02-22 17:00:17 +09:00
commit 337be8438e
231 changed files with 4581 additions and 2160 deletions

View file

@ -15,7 +15,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '2.8.1'
flutter-version: '2.10.1'
- name: Clone the repository.
uses: actions/checkout@v2

View file

@ -17,7 +17,7 @@ jobs:
- uses: subosito/flutter-action@v1
with:
channel: stable
flutter-version: '2.8.1'
flutter-version: '2.10.1'
# Workaround for this Android Gradle Plugin issue (supposedly fixed in AGP 4.1):
# https://issuetracker.google.com/issues/144111441
@ -52,12 +52,12 @@ jobs:
rm release.keystore.asc
mkdir outputs
(cd scripts/; ./apply_flavor_play.sh)
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.8.1.sksl.json
flutter build appbundle -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.1.sksl.json
cp build/app/outputs/bundle/playRelease/*.aab outputs
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.8.1.sksl.json
flutter build apk -t lib/main_play.dart --flavor play --bundle-sksl-path shaders_2.10.1.sksl.json
cp build/app/outputs/apk/play/release/*.apk outputs
(cd scripts/; ./apply_flavor_izzy.sh)
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.8.1.sksl.json
flutter build apk -t lib/main_izzy.dart --flavor izzy --split-per-abi --bundle-sksl-path shaders_2.10.1.sksl.json
cp build/app/outputs/apk/izzy/release/*.apk outputs
rm $AVES_STORE_FILE
env:

View file

@ -4,6 +4,33 @@ All notable changes to this project will be documented in this file.
## <a id="unreleased"></a>[Unreleased]
## <a id="v1.6.0"></a>[v1.6.0] - 2022-02-22
### Added
- optional recycle bin to keep deleted items for 30 days
- Viewer: actions to copy/move to album
- Indonesian translation (thanks MeFinity)
### Changed
- Viewer: action menu reorganization
- Viewer: `Export` action renamed to `Convert`
- Viewer: actual size zoom level respects device pixel ratio
- Viewer: allow zooming out small items to actual size
- Collection: improved performance for sort/group by name
- load previous top items on startup
- locale independent colors for known filters
- upgraded Flutter to stable v2.10.1
### Removed
- Map: connectivity check
### Fixed
- navigating from Album page when picking an item for another app
## <a id="v1.5.11"></a>[v1.5.11] - 2022-01-30
### Added

View file

@ -85,7 +85,7 @@ At this stage this project does *not* accept PRs, except for translations.
### Translations
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German and Spanish are handled by generous volunteers.
If you want to translate this app in your language and share the result, [there is a guide](https://github.com/deckerst/aves/wiki/Contributing-to-Translations). English, Korean and French are already handled by me. Russian, German, Spanish, Portuguese & Indonesian are handled by generous volunteers.
### Donations

View file

@ -141,7 +141,7 @@ repositories {
}
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.exifinterface:exifinterface:1.3.3'
implementation 'androidx.multidex:multidex:2.0.1'
@ -152,10 +152,10 @@ dependencies {
implementation 'com.github.deckerst:Android-TiffBitmapFactory:876e53870a'
// forked, built by JitPack, cf https://jitpack.io/p/deckerst/pixymeta-android
implementation 'com.github.deckerst:pixymeta-android:706bd73d6e'
implementation 'com.github.bumptech.glide:glide:4.12.0'
implementation 'com.github.bumptech.glide:glide:4.13.0'
kapt 'androidx.annotation:annotation:1.3.0'
kapt 'com.github.bumptech.glide:compiler:4.12.0'
kapt 'com.github.bumptech.glide:compiler:4.13.0'
compileOnly rootProject.findProject(':streams_channel')
}

View file

@ -159,10 +159,10 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
COMMAND_START -> {
runBlocking {
FlutterUtils.runOnUiThread {
val contentIds = data.get(KEY_CONTENT_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
val entryIds = data.get(KEY_ENTRY_IDS)?.takeIf { it is IntArray }?.let { (it as IntArray).toList() }
backgroundChannel?.invokeMethod(
"start", hashMapOf(
"contentIds" to contentIds,
"entryIds" to entryIds,
"force" to data.getBoolean(KEY_FORCE),
)
)
@ -197,7 +197,7 @@ class AnalysisService : MethodChannel.MethodCallHandler, Service() {
const val KEY_COMMAND = "command"
const val COMMAND_START = "start"
const val COMMAND_STOP = "stop"
const val KEY_CONTENT_IDS = "content_ids"
const val KEY_ENTRY_IDS = "entry_ids"
const val KEY_FORCE = "force"
}
}

View file

@ -17,15 +17,15 @@ import deckers.thibault.aves.utils.LogUtils
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvider() {
private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun query(uri: Uri, projection: Array<String>?, selection: String?, selectionArgs: Array<String>?, sortOrder: String?): Cursor? {
return selectionArgs?.firstOrNull()?.let { query ->
// Samsung Finder does not support:
@ -77,29 +77,34 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid
val backgroundChannel = MethodChannel(messenger, BACKGROUND_CHANNEL)
backgroundChannel.setMethodCallHandler(this)
return suspendCoroutine { cont ->
GlobalScope.launch {
FlutterUtils.runOnUiThread {
backgroundChannel.invokeMethod("getSuggestions", hashMapOf(
"query" to query,
"locale" to Locale.getDefault().toString(),
"use24hour" to DateFormat.is24HourFormat(context),
), object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("unchecked_cast")
cont.resume(result as List<FieldMap>)
}
try {
return suspendCoroutine { cont ->
defaultScope.launch {
FlutterUtils.runOnUiThread {
backgroundChannel.invokeMethod("getSuggestions", hashMapOf(
"query" to query,
"locale" to Locale.getDefault().toString(),
"use24hour" to DateFormat.is24HourFormat(context),
), object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("unchecked_cast")
cont.resume(result as List<FieldMap>)
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
cont.resumeWithException(Exception("$errorCode: $errorMessage\n$errorDetails"))
}
override fun notImplemented() {
cont.resumeWithException(NotImplementedError("getSuggestions"))
}
})
override fun notImplemented() {
cont.resumeWithException(NotImplementedError("getSuggestions"))
}
})
}
}
}
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to get suggestions", e)
return ArrayList()
}
}

View file

@ -16,14 +16,14 @@ import deckers.thibault.aves.utils.ContextUtils.isMyServiceRunning
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
class AnalysisHandler(private val activity: Activity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler, AnalysisServiceListener {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::registerCallback) }
"registerCallback" -> ioScope.launch { Coresult.safe(call, result, ::registerCallback) }
"startService" -> Coresult.safe(call, result, ::startAnalysis)
else -> result.notImplemented()
}
@ -52,12 +52,12 @@ class AnalysisHandler(private val activity: Activity, private val onAnalysisComp
}
// can be null or empty
val contentIds = call.argument<List<Int>>("contentIds")
val entryIds = call.argument<List<Int>>("entryIds")
if (!activity.isMyServiceRunning(AnalysisService::class.java)) {
val intent = Intent(activity, AnalysisService::class.java)
intent.putExtra(AnalysisService.KEY_COMMAND, AnalysisService.COMMAND_START)
intent.putExtra(AnalysisService.KEY_CONTENT_IDS, contentIds?.toIntArray())
intent.putExtra(AnalysisService.KEY_ENTRY_IDS, entryIds?.toIntArray())
intent.putExtra(AnalysisService.KEY_FORCE, force)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(intent)

View file

@ -33,26 +33,26 @@ import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import java.io.File
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.roundToInt
class AppAdapterHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getAppIcon) }
"copyToClipboard" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::copyToClipboard) }
"getPackages" -> ioScope.launch { safe(call, result, ::getPackages) }
"getAppIcon" -> ioScope.launch { safeSuspend(call, result, ::getAppIcon) }
"copyToClipboard" -> ioScope.launch { safe(call, result, ::copyToClipboard) }
"edit" -> safe(call, result, ::edit)
"open" -> safe(call, result, ::open)
"openMap" -> safe(call, result, ::openMap)
"setAs" -> safe(call, result, ::setAs)
"share" -> safe(call, result, ::share)
"pinShortcut" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pinShortcut) }
"pinShortcut" -> ioScope.launch { safe(call, result, ::pinShortcut) }
else -> result.notImplemented()
}
}

View file

@ -32,33 +32,33 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import io.flutter.util.PathUtils
import kotlinx.coroutines.*
import org.beyka.tiffbitmapfactory.TiffBitmapFactory
import java.io.IOException
import java.util.*
class DebugHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50)
"exception" -> throw TestException()
"safeException" -> safe(call, result) { _, _ -> throw TestException() }
"exceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { throw TestException() }
"safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } }
"exceptionInCoroutine" -> ioScope.launch { throw TestException() }
"safeExceptionInCoroutine" -> ioScope.launch { safe(call, result) { _, _ -> throw TestException() } }
"getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) }
"getContextDirs" -> ioScope.launch { safe(call, result, ::getContextDirs) }
"getCodecs" -> safe(call, result, ::getCodecs)
"getEnv" -> safe(call, result, ::getEnv)
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMetadataExtractorSummary) }
"getPixyMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getTiffStructure) }
"getBitmapFactoryInfo" -> ioScope.launch { safe(call, result, ::getBitmapFactoryInfo) }
"getContentResolverMetadata" -> ioScope.launch { safe(call, result, ::getContentResolverMetadata) }
"getExifInterfaceMetadata" -> ioScope.launch { safe(call, result, ::getExifInterfaceMetadata) }
"getMediaMetadataRetrieverMetadata" -> ioScope.launch { safe(call, result, ::getMediaMetadataRetrieverMetadata) }
"getMetadataExtractorSummary" -> ioScope.launch { safe(call, result, ::getMetadataExtractorSummary) }
"getPixyMetadata" -> ioScope.launch { safe(call, result, ::getPixyMetadata) }
"getTiffStructure" -> ioScope.launch { safe(call, result, ::getTiffStructure) }
else -> result.notImplemented()
}
}
@ -69,6 +69,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
"filesDir" to context.filesDir,
"obbDir" to context.obbDir,
"externalCacheDir" to context.externalCacheDir,
"externalFilesDir" to context.getExternalFilesDir(null),
).apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
putAll(
@ -81,7 +82,18 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
put("dataDir", context.dataDir)
}
}.mapValues { it.value?.path }
}.mapValues { it.value?.path }.toMutableMap()
dirs["externalCacheDirs"] = context.externalCacheDirs.joinToString { it.path }
dirs["externalFilesDirs"] = context.getExternalFilesDirs(null).joinToString { it.path }
// used by flutter plugin `path_provider`
dirs.putAll(
hashMapOf(
"flutter / cacheDir" to PathUtils.getCacheDirectory(context),
"flutter / dataDir" to PathUtils.getDataDirectory(context),
"flutter / filesDir" to PathUtils.getFilesDir(context),
)
)
result.success(dirs)
}

View file

@ -34,20 +34,20 @@ import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import java.io.File
import java.io.InputStream
import java.util.*
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getExifThumbnails) }
"extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
"getExifThumbnails" -> ioScope.launch { safeSuspend(call, result, ::getExifThumbnails) }
"extractMotionPhotoVideo" -> ioScope.launch { safe(call, result, ::extractMotionPhotoVideo) }
"extractVideoEmbeddedPicture" -> ioScope.launch { safe(call, result, ::extractVideoEmbeddedPicture) }
"extractXmpDataProp" -> ioScope.launch { safe(call, result, ::extractXmpDataProp) }
else -> result.notImplemented()
}
}
@ -210,7 +210,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
"mimeType" to mimeType,
)
if (isImage(mimeType) || isVideo(mimeType)) {
GlobalScope.launch(Dispatchers.IO) {
ioScope.launch {
ContentImageProvider().fetchSingle(context, uri, mimeType, object : ImageProvider.ImageOpCallback {
override fun onSuccess(fields: FieldMap) {
resultFields.putAll(fields)

View file

@ -6,9 +6,7 @@ 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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import java.io.IOException
import java.util.*
@ -16,11 +14,12 @@ import java.util.*
// - `geocoder` is unmaintained
// - `geocoding` method does not return `addressLine` (v2.0.0)
class GeocodingHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var geocoder: Geocoder? = null
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAddress" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAddress) }
"getAddress" -> ioScope.launch { safe(call, result, ::getAddress) }
else -> result.notImplemented()
}
}

View file

@ -7,14 +7,14 @@ 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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
class GlobalSearchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"registerCallback" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::registerCallback) }
"registerCallback" -> ioScope.launch { safe(call, result, ::registerCallback) }
else -> result.notImplemented()
}
}

View file

@ -21,24 +21,23 @@ import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlin.math.roundToInt
class MediaFileHandler(private val activity: Activity) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val density = activity.resources.displayMetrics.density
private val regionFetcher = RegionFetcher(activity)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
"getEntry" -> ioScope.launch { safe(call, result, ::getEntry) }
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) }
"getRegion" -> ioScope.launch { safeSuspend(call, result, ::getRegion) }
"cancelFileOp" -> safe(call, result, ::cancelFileOp)
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
"captureFrame" -> ioScope.launch { safeSuspend(call, result, ::captureFrame) }
"clearSizedThumbnailDiskCache" -> ioScope.launch { safe(call, result, ::clearSizedThumbnailDiskCache) }
else -> result.notImplemented()
}
}

View file

@ -8,22 +8,25 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaStoreHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"checkObsoleteContentIds" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::checkObsoletePaths) }
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
"checkObsoleteContentIds" -> ioScope.launch { safe(call, result, ::checkObsoleteContentIds) }
"checkObsoletePaths" -> ioScope.launch { safe(call, result, ::checkObsoletePaths) }
"scanFile" -> ioScope.launch { safe(call, result, ::scanFile) }
else -> result.notImplemented()
}
}
private fun checkObsoleteContentIds(call: MethodCall, result: MethodChannel.Result) {
val knownContentIds = call.argument<List<Int>>("knownContentIds")
val knownContentIds = call.argument<List<Int?>>("knownContentIds")
if (knownContentIds == null) {
result.error("checkObsoleteContentIds-args", "failed because of missing arguments", null)
return
@ -32,7 +35,7 @@ class MediaStoreHandler(private val context: Context) : MethodCallHandler {
}
private fun checkObsoletePaths(call: MethodCall, result: MethodChannel.Result) {
val knownPathById = call.argument<Map<Int, String>>("knownPathById")
val knownPathById = call.argument<Map<Int?, String?>>("knownPathById")
if (knownPathById == null) {
result.error("checkObsoletePaths-args", "failed because of missing arguments", null)
return

View file

@ -10,18 +10,18 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
"editDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editDate) }
"editMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::editMetadata) }
"removeTypes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::removeTypes) }
"rotate" -> ioScope.launch { safe(call, result, ::rotate) }
"flip" -> ioScope.launch { safe(call, result, ::flip) }
"editDate" -> ioScope.launch { safe(call, result, ::editDate) }
"editMetadata" -> ioScope.launch { safe(call, result, ::editMetadata) }
"removeTypes" -> ioScope.launch { safe(call, result, ::removeTypes) }
else -> result.notImplemented()
}
}

View file

@ -74,29 +74,28 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.ParseException
import java.util.*
import kotlin.math.roundToInt
import kotlin.math.roundToLong
class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAllMetadata) }
"getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getCatalogMetadata) }
"getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) }
"getIptc" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getIptc) }
"getXmp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getXmp) }
"hasContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::hasContentResolverProp) }
"getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) }
"getDate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getDate) }
"getAllMetadata" -> ioScope.launch { safe(call, result, ::getAllMetadata) }
"getCatalogMetadata" -> ioScope.launch { safe(call, result, ::getCatalogMetadata) }
"getOverlayMetadata" -> ioScope.launch { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> ioScope.launch { safe(call, result, ::getMultiPageInfo) }
"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) }
"getDate" -> ioScope.launch { safe(call, result, ::getDate) }
else -> result.notImplemented()
}
}
@ -412,19 +411,19 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
// File type
for (dir in metadata.getDirectoriesOfType(FileTypeDirectory::class.java)) {
// * `metadata-extractor` sometimes detects the wrong MIME type (e.g. `pef` file as `tiff`, `mpeg` as `dvd`)
// * `metadata-extractor` sometimes detects the wrong MIME type (e.g. `pef` file as `tiff`, `mpeg` as `dvd`, `avif` as `mov`)
// * the content resolver / media store sometimes reports the wrong MIME type (e.g. `png` file as `jpeg`, `tiff` as `srw`)
// * `context.getContentResolver().getType()` sometimes returns an incorrect value
// * `MediaMetadataRetriever.setDataSource()` sometimes fails with `status = 0x80000000`
// * file extension is unreliable
// In the end, `metadata-extractor` is the most reliable, except for `tiff`/`dvd` (false positives, false negatives),
// In the end, `metadata-extractor` is the most reliable, except for `tiff`/`dvd`/`mov` (false positives, false negatives),
// in which case we trust the file extension
// cf https://github.com/drewnoakes/metadata-extractor/issues/296
if (path?.matches(TIFF_EXTENSION_PATTERN) == true) {
metadataMap[KEY_MIME_TYPE] = MimeTypes.TIFF
} else {
dir.getSafeString(FileTypeDirectory.TAG_DETECTED_FILE_MIME_TYPE) {
if (it != MimeTypes.TIFF && it != MimeTypes.DVD) {
if (it != MimeTypes.TIFF && it != MimeTypes.DVD && it != MimeTypes.MOV) {
metadataMap[KEY_MIME_TYPE] = it
}
}

View file

@ -13,22 +13,24 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.File
import java.util.*
class StorageHandler(private val context: Context) : MethodCallHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) }
"getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
"getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
"getRestrictedDirectories" -> ioScope.launch { safe(call, result, ::getRestrictedDirectories) }
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
"deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) }
"canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess)
"canInsertMedia" -> safe(call, result, ::canInsertMedia)
else -> result.notImplemented()

View file

@ -131,7 +131,7 @@ class ThumbnailFetcher internal constructor(
svgFetch -> SvgImage(context, uri)
tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> StorageUtils.getGlideSafeUri(uri, mimeType)
else -> StorageUtils.getGlideSafeUri(context, uri, mimeType)
}
Glide.with(context)
.asBitmap()

View file

@ -23,12 +23,11 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import java.io.InputStream
class ImageByteStreamHandler(private val context: Context, private val arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
@ -36,7 +35,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
GlobalScope.launch(Dispatchers.IO) { streamImage() }
ioScope.launch { streamImage() }
}
override fun onCancel(o: Any) {}
@ -119,7 +118,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(context, uri, pageId)
} else {
StorageUtils.getGlideSafeUri(uri, mimeType)
StorageUtils.getGlideSafeUri(context, uri, mimeType)
}
val target = Glide.with(context)

View file

@ -11,16 +11,19 @@ import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.model.NameConflictStrategy
import deckers.thibault.aves.model.provider.ImageProvider.ImageOpCallback
import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.*
import java.io.File
class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
@ -45,10 +48,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
handler = Handler(Looper.getMainLooper())
when (op) {
"delete" -> GlobalScope.launch(Dispatchers.IO) { delete() }
"export" -> GlobalScope.launch(Dispatchers.IO) { export() }
"move" -> GlobalScope.launch(Dispatchers.IO) { move() }
"rename" -> GlobalScope.launch(Dispatchers.IO) { rename() }
"delete" -> ioScope.launch { delete() }
"export" -> ioScope.launch { export() }
"move" -> ioScope.launch { move() }
"rename" -> ioScope.launch { rename() }
else -> endOfStream()
}
}
@ -103,12 +106,16 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
val entries = entryMapList.map(::AvesEntry)
for (entry in entries) {
val uri = entry.uri
val path = entry.path
val mimeType = entry.mimeType
val trashed = entry.trashed
val uri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri
val path = if (trashed) entry.trashPath else entry.path
val result: FieldMap = hashMapOf(
"uri" to uri.toString(),
// `uri` should reference original content URI,
// so it is different with `sourceUri` when deleting trashed entries
"uri" to entry.uri.toString(),
)
if (isCancelledOp()) {
result["skipped"] = true
@ -160,30 +167,34 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
}
private suspend fun move() {
if (arguments !is Map<*, *> || entryMapList.isEmpty()) {
if (arguments !is Map<*, *>) {
endOfStream()
return
}
val copy = arguments["copy"] as Boolean?
var destinationDir = arguments["destinationPath"] as String?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (copy == null || destinationDir == null || nameConflictStrategy == null) {
val rawEntryMap = arguments["entriesByDestination"] as Map<*, *>?
if (copy == null || nameConflictStrategy == null || rawEntryMap == null || rawEntryMap.isEmpty()) {
error("move-args", "failed because of missing arguments", null)
return
}
// assume same provider for all entries
val firstEntry = entryMapList.first()
val provider = (firstEntry["uri"] as String?)?.let { Uri.parse(it) }?.let { getProvider(it) }
if (provider == null) {
error("move-provider", "failed to find provider for entry=$firstEntry", null)
return
val entriesByTargetDir = HashMap<String, List<AvesEntry>>()
rawEntryMap.forEach {
var destinationDir = it.key as String
if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) {
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
}
@Suppress("unchecked_cast")
val rawEntries = it.value as List<FieldMap>
entriesByTargetDir[destinationDir] = rawEntries.map(::AvesEntry)
}
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry)
provider.moveMultiple(activity, copy, destinationDir, nameConflictStrategy, entries, ::isCancelledOp, object : ImageOpCallback {
// always use Media Store (as we move from or to it)
val provider = MediaStoreImageProvider()
provider.moveMultiple(activity, copy, nameConflictStrategy, entriesByTargetDir, ::isCancelledOp, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("move-failure", "failed to move entries", throwable)
})

View file

@ -9,20 +9,24 @@ import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
private var knownEntries: Map<Int, Int?>? = null
private var knownEntries: Map<Int?, Int?>? = null
private var directory: String? = null
init {
if (arguments is Map<*, *>) {
@Suppress("unchecked_cast")
knownEntries = arguments["knownEntries"] as Map<Int, Int?>?
knownEntries = arguments["knownEntries"] as Map<Int?, Int?>?
directory = arguments["directory"] as String?
}
}
@ -30,7 +34,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
this.eventSink = eventSink
handler = Handler(Looper.getMainLooper())
GlobalScope.launch(Dispatchers.IO) { fetchAll() }
ioScope.launch { fetchAll() }
}
override fun onCancel(arguments: Any?) {}
@ -56,7 +60,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E
}
private fun fetchAll() {
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap()) { success(it) }
MediaStoreImageProvider().fetchAll(context, knownEntries ?: emptyMap(), directory) { success(it) }
endOfStream()
}

View file

@ -15,14 +15,16 @@ import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.FileOutputStream
// starting activity to give access with the native dialog
// breaks the regular `MethodChannel` so we use a stream channel instead
class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?) : EventChannel.StreamHandler {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private lateinit var eventSink: EventSink
private lateinit var handler: Handler
@ -41,10 +43,10 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
handler = Handler(Looper.getMainLooper())
when (op) {
"requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() }
"requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() }
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
"requestDirectoryAccess" -> ioScope.launch { requestDirectoryAccess() }
"requestMediaFileAccess" -> ioScope.launch { requestMediaFileAccess() }
"createFile" -> ioScope.launch { createFile() }
"openFile" -> ioScope.launch { openFile() }
else -> endOfStream()
}
}
@ -113,7 +115,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
putExtra(Intent.EXTRA_TITLE, name)
}
MainActivity.pendingStorageAccessResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
GlobalScope.launch(Dispatchers.IO) {
ioScope.launch {
try {
activity.contentResolver.openOutputStream(uri)?.use { output ->
output as FileOutputStream
@ -145,7 +147,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
val mimeType = args["mimeType"] as String? // optional
fun onGranted(uri: Uri) {
GlobalScope.launch(Dispatchers.IO) {
ioScope.launch {
activity.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int

View file

@ -18,8 +18,9 @@ import com.bumptech.glide.module.LibraryGlideModule
import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.StorageUtils.openMetadataRetriever
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
import java.io.InputStream
@ -48,8 +49,10 @@ internal class VideoThumbnailLoader : ModelLoader<VideoThumbnail, InputStream> {
}
internal class VideoThumbnailFetcher(private val model: VideoThumbnail) : DataFetcher<InputStream> {
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun loadData(priority: Priority, callback: DataCallback<in InputStream>) {
GlobalScope.launch(Dispatchers.IO) {
ioScope.launch {
val retriever = openMetadataRetriever(model.context, model.uri)
if (retriever == null) {
callback.onLoadFailed(Exception("failed to initialize MediaMetadataRetriever for uri=${model.uri}"))

View file

@ -62,7 +62,7 @@ class GSpherical(xmlBytes: ByteArray) {
}
}
fun describe(): Map<String, String?> = hashMapOf(
fun describe(): Map<String, String> = hashMapOf(
"Spherical" to spherical.toString(),
"Stitched" to stitched.toString(),
"Stitching Software" to stitchingSoftware,
@ -79,7 +79,7 @@ class GSpherical(xmlBytes: ByteArray) {
"Cropped Area Image Height Pixels" to croppedAreaImageHeightPixels?.toString(),
"Cropped Area Left Pixels" to croppedAreaLeftPixels?.toString(),
"Cropped Area Top Pixels" to croppedAreaTopPixels?.toString(),
).filterValues { it != null }
).filterValues { it != null }.mapValues { it.value as String }
companion object SphericalVideo {
private val LOG_TAG = LogUtils.createTag<SphericalVideo>()

View file

@ -11,4 +11,6 @@ class AvesEntry(map: FieldMap) {
val height = map["height"] as Int
val rotationDegrees = map["rotationDegrees"] as Int
val isFlipped = map["isFlipped"] as Boolean
val trashed = map["trashed"] as Boolean
val trashPath = map["trashPath"] as String?
}

View file

@ -1,8 +1,11 @@
package deckers.thibault.aves.model.provider
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.util.Log
import deckers.thibault.aves.model.SourceEntry
import deckers.thibault.aves.utils.LogUtils
import java.io.File
internal class FileImageProvider : ImageProvider() {
@ -33,4 +36,18 @@ internal class FileImageProvider : ImageProvider() {
callback.onFailure(Exception("entry has no size"))
}
}
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
val file = File(File(uri.path!!).path)
if (!file.exists()) return
Log.d(LOG_TAG, "delete file at uri=$uri")
if (file.delete()) return
throw Exception("failed to delete entry with uri=$uri path=$path")
}
companion object {
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
}
}

View file

@ -39,7 +39,6 @@ import java.io.File
import java.io.IOException
import java.io.OutputStream
import java.util.*
import kotlin.collections.HashMap
abstract class ImageProvider {
open fun fetchSingle(context: Context, uri: Uri, sourceMimeType: String?, callback: ImageOpCallback) {
@ -53,9 +52,8 @@ abstract class ImageProvider {
open suspend fun moveMultiple(
activity: Activity,
copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
entriesByTargetDir: Map<String, List<AvesEntry>>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
@ -133,7 +131,6 @@ abstract class ImageProvider {
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun exportSingle(
activity: Activity,
sourceEntry: AvesEntry,
@ -174,6 +171,7 @@ abstract class ImageProvider {
targetMimeType = sourceMimeType
write = { output ->
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext")
sourceDocFile.copyTo(output)
}
} else {
@ -184,7 +182,7 @@ abstract class ImageProvider {
} else if (sourceMimeType == MimeTypes.SVG) {
SvgImage(activity, sourceUri)
} else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType)
}
// request a fresh image with the highest quality format
@ -198,6 +196,7 @@ abstract class ImageProvider {
.apply(glideOptions)
.load(model)
.submit(width, height)
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)
@ -244,7 +243,6 @@ abstract class ImageProvider {
// clearing Glide target should happen after effectively writing the bitmap
Glide.with(activity).clear(target)
}
}
@Suppress("BlockingMethodInNonBlockingContext")

View file

@ -3,6 +3,7 @@ package deckers.thibault.aves.model.provider
import android.annotation.SuppressLint
import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
@ -26,24 +27,55 @@ import deckers.thibault.aves.utils.MimeTypes.isImage
import deckers.thibault.aves.utils.MimeTypes.isVideo
import deckers.thibault.aves.utils.StorageUtils
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator
import deckers.thibault.aves.utils.StorageUtils.removeTrailingSeparator
import deckers.thibault.aves.utils.UriUtils.tryParseId
import java.io.File
import java.io.OutputStream
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class MediaStoreImageProvider : ImageProvider() {
fun fetchAll(context: Context, knownEntries: Map<Int, Int?>, handleNewEntry: NewEntryHandler) {
fun fetchAll(
context: Context,
knownEntries: Map<Int?, Int?>,
directory: String?,
handleNewEntry: NewEntryHandler,
) {
val isModified = fun(contentId: Int, dateModifiedSecs: Int): Boolean {
val knownDate = knownEntries[contentId]
return knownDate == null || knownDate < dateModifiedSecs
}
fetchFrom(context, isModified, handleNewEntry, IMAGE_CONTENT_URI, IMAGE_PROJECTION)
fetchFrom(context, isModified, handleNewEntry, VIDEO_CONTENT_URI, VIDEO_PROJECTION)
val handleNew: NewEntryHandler
var selection: String? = null
var selectionArgs: Array<String>? = null
if (directory != null) {
val relativePathDirectory = ensureTrailingSeparator(directory)
val relativePath = PathSegments(context, relativePathDirectory).relativeDir
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && relativePath != null) {
selection = "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaColumns.PATH} LIKE ?"
selectionArgs = arrayOf(relativePath, "relativePathDirectory%")
} else {
selection = "${MediaColumns.PATH} LIKE ?"
selectionArgs = arrayOf("$relativePathDirectory%")
}
val parentCheckDirectory = removeTrailingSeparator(directory)
handleNew = { entry ->
// skip entries in subfolders
val path = entry["path"] as String?
if (path != null && File(path).parent == parentCheckDirectory) {
handleNewEntry(entry)
}
}
} else {
handleNew = handleNewEntry
}
fetchFrom(context, isModified, handleNew, IMAGE_CONTENT_URI, IMAGE_PROJECTION, selection, selectionArgs)
fetchFrom(context, isModified, handleNew, VIDEO_CONTENT_URI, VIDEO_PROJECTION, selection, selectionArgs)
}
// the provided URI can point to the wrong media collection,
@ -83,7 +115,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
}
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int>): List<Int> {
fun checkObsoleteContentIds(context: Context, knownContentIds: List<Int?>): List<Int> {
val foundContentIds = HashSet<Int>()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID)
@ -102,10 +134,10 @@ class MediaStoreImageProvider : ImageProvider() {
}
check(context, IMAGE_CONTENT_URI)
check(context, VIDEO_CONTENT_URI)
return knownContentIds.subtract(foundContentIds).toList()
return knownContentIds.subtract(foundContentIds).filterNotNull().toList()
}
fun checkObsoletePaths(context: Context, knownPathById: Map<Int, String>): List<Int> {
fun checkObsoletePaths(context: Context, knownPathById: Map<Int?, String?>): List<Int> {
val obsoleteIds = ArrayList<Int>()
fun check(context: Context, contentUri: Uri) {
val projection = arrayOf(MediaStore.MediaColumns._ID, MediaColumns.PATH)
@ -138,12 +170,14 @@ class MediaStoreImageProvider : ImageProvider() {
handleNewEntry: NewEntryHandler,
contentUri: Uri,
projection: Array<String>,
selection: String? = null,
selectionArgs: Array<String>? = null,
fileMimeType: String? = null,
): Boolean {
var found = false
val orderBy = "${MediaStore.MediaColumns.DATE_MODIFIED} DESC"
try {
val cursor = context.contentResolver.query(contentUri, projection, null, null, orderBy)
val cursor = context.contentResolver.query(contentUri, projection, selection, selectionArgs, orderBy)
if (cursor != null) {
val contentUriContainsId = when (contentUri) {
IMAGE_CONTENT_URI, VIDEO_CONTENT_URI -> false
@ -291,6 +325,10 @@ class MediaStoreImageProvider : ImageProvider() {
}
throw Exception("failed to delete document with df=$df")
}
} else if (uri.scheme?.lowercase(Locale.ROOT) == ContentResolver.SCHEME_FILE) {
val uriFilePath = File(uri.path!!).path
// URI and path both point to the same non existent path
if (uriFilePath == path) return
}
try {
@ -329,84 +367,119 @@ class MediaStoreImageProvider : ImageProvider() {
override suspend fun moveMultiple(
activity: Activity,
copy: Boolean,
targetDir: String,
nameConflictStrategy: NameConflictStrategy,
entries: List<AvesEntry>,
entriesByTargetDir: Map<String, List<AvesEntry>>,
isCancelledOp: CancelCheck,
callback: ImageOpCallback,
) {
val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (!File(targetDir).exists()) {
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
return
}
entriesByTargetDir.forEach { kv ->
val targetDir = kv.key
val entries = kv.value
for (entry in entries) {
val sourceUri = entry.uri
val sourcePath = entry.path
val mimeType = entry.mimeType
val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER
val result: FieldMap = hashMapOf(
"uri" to sourceUri.toString(),
"success" to false,
)
if (sourcePath != null) {
// on API 30 we cannot get access granted directly to a volume root from its document tree,
// but it is still less constraining to use tree document files than to rely on the Media Store
//
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name
//
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
// with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - 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 {
val newFields = if (isCancelledOp()) skippedFieldMap else moveSingle(
activity = activity,
sourcePath = sourcePath,
sourceUri = sourceUri,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
nameConflictStrategy = nameConflictStrategy,
mimeType = mimeType,
copy = copy,
)
result["newFields"] = newFields
result["success"] = true
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
var effectiveTargetDir: String? = null
var targetDirDocFile: DocumentFileCompat? = null
if (!toBin) {
effectiveTargetDir = targetDir
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
if (!File(targetDir).exists()) {
callback.onFailure(Exception("failed to create directory at path=$targetDir"))
return
}
}
callback.onSuccess(result)
for (entry in entries) {
val mimeType = entry.mimeType
val trashed = entry.trashed
val sourceUri = if (trashed) Uri.fromFile(File(entry.trashPath!!)) else entry.uri
val sourcePath = if (trashed) entry.trashPath else entry.path
var desiredName: String? = null
if (trashed) {
entry.path?.let { desiredName = File(it).name }
}
val result: FieldMap = hashMapOf(
// `uri` should reference original content URI,
// so it is different with `sourceUri` when recycling trashed entries
"uri" to entry.uri.toString(),
"success" to false,
)
if (sourcePath != null) {
// on API 30 we cannot get access granted directly to a volume root from its document tree,
// but it is still less constraining to use tree document files than to rely on the Media Store
//
// Relying on `DocumentFile`, we can create an item via `DocumentFile.createFile()`, but:
// - we need to scan the file to get the Media Store content URI
// - the underlying document provider controls the new file name
//
// Relying on the Media Store, we can create an item via `ContentResolver.insert()`
// with a path, and retrieve its content URI, but:
// - the Media Store isolates content by storage volume (e.g. `MediaStore.Images.Media.getContentUri(volumeName)`)
// - the volume name should be lower case, not exactly as the `StorageVolume` UUID
// cf new method in API 30 `StorageVolume.getMediaStoreVolumeName()`
// - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?)
// - 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)
}
}
if (effectiveTargetDir != null) {
val newFields = if (isCancelledOp()) skippedFieldMap else {
val sourceFile = File(sourcePath)
moveSingle(
activity = activity,
sourceFile = sourceFile,
sourceUri = sourceUri,
targetDir = effectiveTargetDir,
targetDirDocFile = targetDirDocFile,
desiredName = desiredName ?: sourceFile.name,
nameConflictStrategy = nameConflictStrategy,
mimeType = mimeType,
copy = copy,
toBin = toBin,
)
}
result["newFields"] = newFields
result["success"] = true
}
} catch (e: Exception) {
Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e)
}
}
callback.onSuccess(result)
}
}
}
private suspend fun moveSingle(
activity: Activity,
sourcePath: String,
sourceFile: File,
sourceUri: Uri,
targetDir: String,
targetDirDocFile: DocumentFileCompat?,
desiredName: String,
nameConflictStrategy: NameConflictStrategy,
mimeType: String,
copy: Boolean,
toBin: Boolean,
): FieldMap {
val sourceFile = File(sourcePath)
val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) }
val sourcePath = sourceFile.path
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) {
// nothing to do unless it's a renamed copy
return skippedFieldMap
}
val sourceFileName = sourceFile.name
val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "")
val desiredNameWithoutExtension = desiredName.replaceFirst(FILE_EXTENSION_PATTERN, "")
val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension(
activity = activity,
dir = targetDir,
@ -432,7 +505,12 @@ class MediaStoreImageProvider : ImageProvider() {
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
}
}
if (toBin) {
return hashMapOf(
"trashed" to true,
"trashPath" to targetPath,
)
}
return scanNewPath(activity, targetPath, mimeType)
}
@ -493,10 +571,7 @@ class MediaStoreImageProvider : ImageProvider() {
}
private fun isDownloadDir(context: Context, dirPath: String): Boolean {
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
if (relativeDir.endsWith(File.separator)) {
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
}
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "")
return relativeDir == Environment.DIRECTORY_DOWNLOADS
}

View file

@ -6,6 +6,7 @@ object MimeTypes {
const val ANY = "*/*"
// generic raster
private const val AVIF = "image/avif"
const val BMP = "image/bmp"
private const val DJVU = "image/vnd.djvu"
const val GIF = "image/gif"
@ -49,7 +50,7 @@ object MimeTypes {
private const val AVI_VND = "video/vnd.avi"
const val DVD = "video/dvd"
private const val MKV = "video/x-matroska"
private const val MOV = "video/quicktime"
const val MOV = "video/quicktime"
private const val MP2T = "video/mp2t"
private const val MP2TS = "video/mp2ts"
const val MP4 = "video/mp4"
@ -72,7 +73,7 @@ object MimeTypes {
// returns whether the specified MIME type represents
// a raster image format that allows an alpha channel
fun canHaveAlpha(mimeType: String?) = when (mimeType) {
BMP, GIF, ICO, PNG, SVG, TIFF, WEBP -> true
AVIF, BMP, GIF, ICO, PNG, SVG, TIFF, WEBP -> true
else -> false
}
@ -150,6 +151,7 @@ object MimeTypes {
fun extensionFor(mimeType: String): String? = when (mimeType) {
ARW -> ".arw"
AVI, AVI_VND -> ".avi"
AVIF -> ".avif"
BMP -> ".bmp"
CR2 -> ".cr2"
CRW -> ".crw"

View file

@ -18,9 +18,7 @@ import deckers.thibault.aves.PendingStorageAccessResultHandler
import deckers.thibault.aves.model.FieldMap
import deckers.thibault.aves.utils.StorageUtils.PathSegments
import java.io.File
import java.util.*
import java.util.concurrent.CompletableFuture
import kotlin.collections.ArrayList
object PermissionManager {
private val LOG_TAG = LogUtils.createTag<PermissionManager>()
@ -94,11 +92,12 @@ object PermissionManager {
}
fun getInaccessibleDirectories(context: Context, dirPaths: List<String>): List<Map<String, String>> {
val concreteDirPaths = dirPaths.filter { it != StorageUtils.TRASH_PATH_PLACEHOLDER }
val accessibleDirs = getAccessibleDirs(context)
// find set of inaccessible directories for each volume
val dirsPerVolume = HashMap<String, MutableSet<String>>()
for (dirPath in dirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) {
for (dirPath in concreteDirPaths.map { if (it.endsWith(File.separator)) it else it + File.separator }) {
if (accessibleDirs.none { dirPath.startsWith(it) }) {
// inaccessible dirs
val segments = PathSegments(context, dirPath)
@ -211,7 +210,8 @@ object PermissionManager {
)
})
} else if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH) {
|| Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT_WATCH
) {
// removable storage requires access permission, at the file level
// without directory access, we consider the whole volume restricted
val primaryVolume = StorageUtils.getPrimaryVolumePath(context)

View file

@ -33,6 +33,32 @@ object StorageUtils {
private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/"
private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
const val TRASH_PATH_PLACEHOLDER = "#trash"
private fun isAppFile(context: Context, path: String): Boolean {
return context.getExternalFilesDirs(null).any { filesDir -> path.startsWith(filesDir.path) }
}
private fun appExternalFilesDirFor(context: Context, path: String): File? {
val filesDirs = context.getExternalFilesDirs(null)
val volumePath = getVolumePath(context, path)
return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.first()
}
fun trashDirFor(context: Context, path: String): File? {
val filesDir = appExternalFilesDirFor(context, path)
if (filesDir == null) {
Log.e(LOG_TAG, "failed to find external files dir for path=$path")
return null
}
val trashDir = File(filesDir, "trash")
if (!trashDir.exists() && !trashDir.mkdirs()) {
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
return null
}
return trashDir
}
/**
* Volume paths
*/
@ -268,10 +294,7 @@ object StorageUtils {
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForTreeUri(context, dirPath)
if (uuid != null) {
var relativeDir = PathSegments(context, dirPath).relativeDir ?: ""
if (relativeDir.endsWith(File.separator)) {
relativeDir = relativeDir.substring(0, relativeDir.length - 1)
}
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "")
return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir")
}
Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI")
@ -329,7 +352,7 @@ object StorageUtils {
// try to strip user info, if any
if (mediaUri.userInfo != null) {
val genericMediaUri = Uri.parse(mediaUri.toString().replaceFirst("${mediaUri.userInfo}@", ""))
val genericMediaUri = stripMediaUriUserInfo(mediaUri)
Log.d(LOG_TAG, "retry getDocumentFile for mediaUri=$mediaUri without userInfo: $genericMediaUri")
return getDocumentFile(context, anyPath, genericMediaUri)
}
@ -408,10 +431,11 @@ object StorageUtils {
fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path)
fun requireAccessPermission(context: Context, anyPath: String): Boolean {
if (isAppFile(context, anyPath)) return false
// on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
return true
}
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) return true
val onPrimaryVolume = anyPath.startsWith(getPrimaryVolumePath(context))
return !onPrimaryVolume
}
@ -442,36 +466,71 @@ object StorageUtils {
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
// to work around a bug from Android Q where metadata redaction corrupts HEIC images.
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some content URIs (e.g. `content://media/external_primary/downloads/...`)
// so we build a typical `images` or `videos` content URI from the original content ID.
fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
// requesting access or writing to some MediaStore content URIs
// e.g. `content://0@media/...`, `content://media/external_primary/downloads/...`
// yields an exception with `All requested items must be referenced by specific ID`
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri {
// for some non image/video content URIs (e.g. `downloads`, `file`)
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
// we cannot safely apply this to a file content URI, as it may point to a file not indexed
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI
if (uri.path?.contains("/downloads/") == true) {
uri.tryParseId()?.let { id ->
return when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
val uriPath = uri.path
when {
uriPath?.contains("/downloads/") == true -> {
// e.g. `content://media/external_primary/downloads/...`
getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri }
}
uriPath?.contains("/file/") == true -> {
// e.g. `content://media/external/file/...`
// create an ad-hoc temporary file for decoding only
File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
openInputStream(context, uri)?.use { input ->
input.copyTo(output)
}
}
return Uri.fromFile(this)
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to create temporary file from uri=$uri", e)
}
}
}
} else if (uri.userInfo != null) {
// strip user info, if any
return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
}
}
return uri
}
// requesting access or writing to some MediaStore content URIs
// yields an exception with `All requested items must be referenced by specific ID`
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val uriPath = uri.path
when {
uriPath?.contains("/downloads/") == true -> {
// e.g. `content://media/external_primary/downloads/...`
getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri }
}
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
}
}
return uri
}
// Build a typical `images` or `videos` content URI from the original content ID.
// We cannot safely apply this to a `file` content URI, as it may point to a file not indexed
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI.
private fun getMediaUriImageVideoUri(uri: Uri, mimeType: String): Uri? {
return uri.tryParseId()?.let { id ->
return when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
}
}
// strip user info, if any
// e.g. `content://0@media/...`
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
fun openInputStream(context: Context, uri: Uri): InputStream? {
val effectiveUri = getOriginalUri(context, uri)
return try {
@ -517,6 +576,10 @@ object StorageUtils {
return if (dirPath.endsWith(File.separator)) dirPath else dirPath + File.separator
}
fun removeTrailingSeparator(dirPath: String): String {
return if (dirPath.endsWith(File.separator)) dirPath.substring(0, dirPath.length - 1) else dirPath
}
// `fullPath` should match "volumePath + relativeDir + fileName"
class PathSegments(context: Context, fullPath: String) {
var volumePath: String? = null // `volumePath` with trailing "/"

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Aves</string>
<string name="search_shortcut_short_label">Cari</string>
<string name="videos_shortcut_short_label">Video</string>
<string name="analysis_channel_name">Pindai media</string>
<string name="analysis_service_description">Pindai gambar &amp; video</string>
<string name="analysis_notification_default_title">Memindai media</string>
<string name="analysis_notification_action_stop">Berhenti</string>
</resources>

View file

@ -6,7 +6,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.0'
classpath 'com.android.tools.build:gradle:7.1.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
// GMS & Firebase Crashlytics are not actually used by all flavors
classpath 'com.google.gms:google-services:4.3.10'

View file

@ -0,0 +1,5 @@
In v1.6.0:
- recycle bin
- view small and large images at their actual size
- enjoy the app in Indonesian
Full changelog available on GitHub

View file

@ -0,0 +1,5 @@
<i>Aves</i> dapat menangani semua jenis gambar dan video, termasuk JPEG dan MP4, tetapi juga hal-hal yang lebih eksotis seperti <b>TIFF halaman-multi, SVG, AVI lama, dan lainnya</b>! Ini memindai koleksi media Anda untuk mengidentifikasi <b>foto bergerak</b>, <b>panorama</b> (foto 360), <b>video 360°</b>, dan file <b>GeoTIFF</b>.
<b>Navigasi dan pencarian</b> merupakan bagian penting dari <i>Aves</i>. Tujuannya adalah agar pengguna dengan mudah mengalir dari album ke foto ke tag ke peta, dll.
<i>Aves</i> terintegrasi dengan Android (dari <b>API 19 ke 32</b>, yaitu dari KitKat ke Android 12L) dengan fitur-fitur seperti <b>pintasan aplikasi</b> dan <b>pencarian global</b> penanganan. Ini juga berfungsi sebagai <b>penampil dan pemilih media</b>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

View file

@ -0,0 +1 @@
Galeri dan penjelajah metadata

View file

@ -1,11 +1,11 @@
enum AppMode { main, pickExternal, pickInternal, view }
enum AppMode { main, pickMediaExternal, pickMediaInternal, pickFilterInternal, view }
extension ExtraAppMode on AppMode {
bool get canSearch => this == AppMode.main || this == AppMode.pickExternal;
bool get canSearch => this == AppMode.main || this == AppMode.pickMediaExternal;
bool get canSelect => this == AppMode.main;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickExternal;
bool get hasDrawer => this == AppMode.main || this == AppMode.pickMediaExternal;
bool get isPicking => this == AppMode.pickExternal || this == AppMode.pickInternal;
bool get isPickingMedia => this == AppMode.pickMediaExternal || this == AppMode.pickMediaInternal;
}

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 Sekunde} other{{seconds} Sekunde}}",
"timeMinutes": "{minutes, plural, =1{1 Minute} other{{minutes} Minuten}}",
"timeDays": "{days, plural, =1{1 Tag} other{{days} Tage}}",
"focalLength": "{length} mm",
"applyButtonLabel": "ANWENDEN",
@ -27,6 +28,7 @@
"resetButtonTooltip": "Zurücksetzen",
"doubleBackExitMessage": "Zum Verlassen erneut auf „Zurück“ tippen.",
"doNotAskAgain": "Nicht noch einmal fragen",
"sourceStateLoading": "Laden",
"sourceStateCataloguing": "Katalogisierung",
@ -47,8 +49,8 @@
"entryActionCopyToClipboard": "In die Zwischenablage kopieren",
"entryActionDelete": "Löschen",
"entryActionExport": "Exportieren",
"entryActionInfo": "Info",
"entryActionRename": "Umbenennen",
"entryActionRestore": "Wiederherstellen",
"entryActionRotateCCW": "Drehen gegen den Uhrzeigersinn",
"entryActionRotateCW": "Drehen im Uhrzeigersinn",
"entryActionFlip": "Horizontal spiegeln",
@ -56,10 +58,10 @@
"entryActionShare": "Teilen",
"entryActionViewSource": "Quelle anzeigen",
"entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen",
"entryActionEdit": "Bearbeiten mit...",
"entryActionOpen": "Öffnen mit...",
"entryActionSetAs": "Einstellen als...",
"entryActionOpenMap": "In der Karten-App anzeigen...",
"entryActionEdit": "Bearbeiten",
"entryActionOpen": "Öffnen mit",
"entryActionSetAs": "Einstellen als",
"entryActionOpenMap": "In der Karten-App anzeigen",
"entryActionRotateScreen": "Bildschirm rotieren",
"entryActionAddFavourite": "Zu Favoriten hinzufügen ",
"entryActionRemoveFavourite": "Aus Favoriten entfernen",
@ -79,6 +81,7 @@
"entryInfoActionEditTags": "Tags bearbeiten",
"entryInfoActionRemoveMetadata": "Metadaten entfernen",
"filterBinLabel": "Papierkorb",
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Ungeortet",
"filterTagEmptyLabel": "Unmarkiert",
@ -157,6 +160,7 @@
"noMatchingAppDialogTitle": "Keine passende App",
"noMatchingAppDialogMessage": "Es gibt keine Anwendungen, die dies bewältigen können.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Dieses Element in den Papierkorb verschieben?} other{Diese {count} Elemente in den Papierkorb verschieben?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Sicher, dass dieses Element gelöscht werden soll?} other{Sicher, dass diese {count} Elemente gelöscht werden sollen?}}",
"videoResumeDialogMessage": "Soll bei {time} weiter abspielt werden?",
@ -267,11 +271,12 @@
"collectionPageTitle": "Sammlung",
"collectionPickPageTitle": "Wähle",
"collectionSelectionPageTitle": "{count, plural, =0{Elemente auswählen} =1{1 Element} other{{count} Elemente}}",
"collectionSelectPageTitle": "Elemente auswählen",
"collectionActionShowTitleSearch": "Titelfilter anzeigen",
"collectionActionHideTitleSearch": "Titelfilter ausblenden",
"collectionActionAddShortcut": "Verknüpfung hinzufügen",
"collectionActionEmptyBin": "Papierkorb leeren",
"collectionActionCopy": "In Album kopieren",
"collectionActionMove": "Zum Album verschieben",
"collectionActionRescan": "Neu scannen",
@ -350,6 +355,8 @@
"tagPageTitle": "Tags",
"tagEmpty": "Keine Tags",
"binPageTitle": "Papierkorb",
"searchCollectionFieldHint": "Sammlung durchsuchen",
"searchSectionRecent": "Neueste",
"searchSectionAlbums": "Alben",
@ -375,6 +382,11 @@
"settingsKeepScreenOnTitle": "Bildschirm eingeschaltet lassen",
"settingsDoubleBackExit": "Zum Verlassen zweimal „zurück“ tippen",
"settingsConfirmationDialogTile": "Bestätigungsdialoge",
"settingsConfirmationDialogTitle": "Bestätigungsdialoge",
"settingsConfirmationDialogDeleteItems": "Vor dem endgültigen Löschen von Elementen fragen",
"settingsConfirmationDialogMoveToBinItems": "Vor dem Verschieben von Elementen in den Papierkorb fragen",
"settingsNavigationDrawerTile": "Menü Navigation",
"settingsNavigationDrawerEditorTitle": "Menü Navigation",
"settingsNavigationDrawerBanner": "Die Taste berühren und halten, um Menüpunkte zu verschieben und neu anzuordnen.",
@ -450,6 +462,8 @@
"settingsAllowInstalledAppAccessSubtitle": "zur Gruppierung von Bildern nach Apps",
"settingsAllowErrorReporting": "Anonyme Fehlermeldungen zulassen",
"settingsSaveSearchHistory": "Suchverlauf speichern",
"settingsEnableBin": "Papierkorb verwenden",
"settingsEnableBinSubtitle": "Gelöschte Elemente 30 Tage lang aufbewahren",
"settingsHiddenItemsTile": "Versteckte Elemente",
"settingsHiddenItemsTitle": "Versteckte Elemente",

View file

@ -22,6 +22,12 @@
"minutes": {}
}
},
"timeDays": "{days, plural, =1{1 day} other{{days} days}}",
"@timeDays": {
"placeholders": {
"days": {}
}
},
"focalLength": "{length} mm",
"@focalLength": {
"placeholders": {
@ -50,6 +56,7 @@
"resetButtonTooltip": "Reset",
"doubleBackExitMessage": "Tap “back” again to exit.",
"doNotAskAgain": "Do not ask again",
"sourceStateLoading": "Loading",
"sourceStateCataloguing": "Cataloguing",
@ -69,9 +76,10 @@
"entryActionCopyToClipboard": "Copy to clipboard",
"entryActionDelete": "Delete",
"entryActionConvert": "Convert",
"entryActionExport": "Export",
"entryActionInfo": "Info",
"entryActionRename": "Rename",
"entryActionRestore": "Restore",
"entryActionRotateCCW": "Rotate counterclockwise",
"entryActionRotateCW": "Rotate clockwise",
"entryActionFlip": "Flip horizontally",
@ -79,10 +87,10 @@
"entryActionShare": "Share",
"entryActionViewSource": "View source",
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
"entryActionEdit": "Edit with…",
"entryActionOpen": "Open with",
"entryActionSetAs": "Set as",
"entryActionOpenMap": "Show in map app",
"entryActionEdit": "Edit",
"entryActionOpen": "Open with",
"entryActionSetAs": "Set as",
"entryActionOpenMap": "Show in map app",
"entryActionRotateScreen": "Rotate screen",
"entryActionAddFavourite": "Add to favourites",
"entryActionRemoveFavourite": "Remove from favourites",
@ -102,6 +110,7 @@
"entryInfoActionEditTags": "Edit tags",
"entryInfoActionRemoveMetadata": "Remove metadata",
"filterBinLabel": "Recycle bin",
"filterFavouriteLabel": "Favourite",
"filterLocationEmptyLabel": "Unlocated",
"filterTagEmptyLabel": "Untagged",
@ -254,7 +263,13 @@
"noMatchingAppDialogTitle": "No Matching App",
"noMatchingAppDialogMessage": "There are no apps that can handle this.",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this item?} other{Are you sure you want to delete these {count} items?}}",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Move this item to the recycle bin?} other{Move these {count} items to the recycle bin?}}",
"@binEntriesConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Delete this item?} other{Delete these {count} items?}}",
"@deleteEntriesConfirmationDialogMessage": {
"placeholders": {
"count": {}
@ -287,13 +302,13 @@
"renameAlbumDialogLabel": "New name",
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete this album and its item?} other{Are you sure you want to delete this album and its {count} items?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Delete this album and its item?} other{Delete this album and its {count} items?}}",
"@deleteSingleAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
}
},
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Are you sure you want to delete these albums and their item?} other{Are you sure you want to delete these albums and their {count} items?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Delete these albums and their item?} other{Delete these albums and their {count} items?}}",
"@deleteMultiAlbumConfirmationDialogMessage": {
"placeholders": {
"count": {}
@ -399,16 +414,12 @@
"collectionPageTitle": "Collection",
"collectionPickPageTitle": "Pick",
"collectionSelectionPageTitle": "{count, plural, =0{Select items} =1{1 item} other{{count} items}}",
"@collectionSelectionPageTitle": {
"placeholders": {
"count": {}
}
},
"collectionSelectPageTitle": "Select items",
"collectionActionShowTitleSearch": "Show title filter",
"collectionActionHideTitleSearch": "Hide title filter",
"collectionActionAddShortcut": "Add shortcut",
"collectionActionEmptyBin": "Empty bin",
"collectionActionCopy": "Copy to album",
"collectionActionMove": "Move to album",
"collectionActionRescan": "Rescan",
@ -527,6 +538,8 @@
"tagPageTitle": "Tags",
"tagEmpty": "No tags",
"binPageTitle": "Recycle Bin",
"searchCollectionFieldHint": "Search collection",
"searchSectionRecent": "Recent",
"searchSectionAlbums": "Albums",
@ -552,6 +565,11 @@
"settingsKeepScreenOnTitle": "Keep Screen On",
"settingsDoubleBackExit": "Tap “back” twice to exit",
"settingsConfirmationDialogTile": "Confirmation dialogs",
"settingsConfirmationDialogTitle": "Confirmation Dialogs",
"settingsConfirmationDialogDeleteItems": "Ask before deleting items forever",
"settingsConfirmationDialogMoveToBinItems": "Ask before moving items to the recycle bin",
"settingsNavigationDrawerTile": "Navigation menu",
"settingsNavigationDrawerEditorTitle": "Navigation Menu",
"settingsNavigationDrawerBanner": "Touch and hold to move and reorder menu items.",
@ -627,6 +645,8 @@
"settingsAllowInstalledAppAccessSubtitle": "Used to improve album display",
"settingsAllowErrorReporting": "Allow anonymous error reporting",
"settingsSaveSearchHistory": "Save search history",
"settingsEnableBin": "Use recycle bin",
"settingsEnableBinSubtitle": "Keep deleted items for 30 days",
"settingsHiddenItemsTile": "Hidden items",
"settingsHiddenItemsTitle": "Hidden Items",

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
"timeDays": "{days, plural, =1{1 día} other{{days} días}}",
"focalLength": "{length} mm",
"applyButtonLabel": "APLICAR",
@ -27,6 +28,7 @@
"resetButtonTooltip": "Restablecer",
"doubleBackExitMessage": "Presione «atrás» nuevamente para salir.",
"doNotAskAgain": "No preguntar nuevamente",
"sourceStateLoading": "Cargando",
"sourceStateCataloguing": "Catalogando",
@ -46,9 +48,10 @@
"entryActionCopyToClipboard": "Copiar al portapapeles",
"entryActionDelete": "Borrar",
"entryActionConvert": "Convertir",
"entryActionExport": "Exportar",
"entryActionInfo": "Información",
"entryActionRename": "Renombrar",
"entryActionRestore": "Restaurar",
"entryActionRotateCCW": "Rotar en sentido antihorario",
"entryActionRotateCW": "Rotar en sentido horario",
"entryActionFlip": "Voltear horizontalmente",
@ -56,10 +59,10 @@
"entryActionShare": "Compartir",
"entryActionViewSource": "Ver fuente",
"entryActionViewMotionPhotoVideo": "Abrir foto en movimiento",
"entryActionEdit": "Editar con…",
"entryActionOpen": "Abrir con",
"entryActionSetAs": "Establecer como",
"entryActionOpenMap": "Mostrar en aplicación de mapa",
"entryActionEdit": "Editar",
"entryActionOpen": "Abrir con",
"entryActionSetAs": "Establecer como",
"entryActionOpenMap": "Mostrar en aplicación de mapa",
"entryActionRotateScreen": "Rotar pantalla",
"entryActionAddFavourite": "Agregar a favoritos",
"entryActionRemoveFavourite": "Quitar de favoritos",
@ -74,10 +77,12 @@
"videoActionSettings": "Ajustes",
"entryInfoActionEditDate": "Editar fecha y hora",
"entryInfoActionEditLocation": "Editar ubicación",
"entryInfoActionEditRating": "Editar clasificación",
"entryInfoActionEditTags": "Editar etiquetas",
"entryInfoActionRemoveMetadata": "Eliminar metadatos",
"filterBinLabel": "Cesto de basura",
"filterFavouriteLabel": "Favorito",
"filterLocationEmptyLabel": "No localizado",
"filterTagEmptyLabel": "Sin etiquetar",
@ -157,6 +162,8 @@
"noMatchingAppDialogTitle": "Sin aplicación compatible",
"noMatchingAppDialogMessage": "No se encontraron aplicaciones para manejar esto.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{¿Mover este elemento al cesto de basura?} other{¿Mover estos {count} elementos al cesto de basura?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de borrar este elemento?} other{¿Está seguro de querer borrar {count} elementos?}}",
"videoResumeDialogMessage": "¿Desea reanudar la reproducción a las {time}?",
@ -181,6 +188,8 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{¿Está seguro de que desea borrar estos álbumes y un elemento?} other{¿Está seguro de que desea borrar estos álbumes y sus {count} elementos?}}",
"exportEntryDialogFormat": "Formato:",
"exportEntryDialogWidth": "Anchura",
"exportEntryDialogHeight": "Altura",
"renameEntryDialogLabel": "Renombrar",
@ -194,6 +203,13 @@
"editEntryDateDialogHours": "Horas",
"editEntryDateDialogMinutes": "Minutos",
"editEntryLocationDialogTitle": "Ubicación",
"editEntryLocationDialogChooseOnMapTooltip": "Elegir en el mapa",
"editEntryLocationDialogLatitude": "Latitud",
"editEntryLocationDialogLongitude": "Longitud",
"locationPickerUseThisLocationButton": "Usar esta ubicación",
"editEntryRatingDialogTitle": "Clasificación",
"removeEntryMetadataDialogTitle": "Eliminación de metadatos",
@ -258,11 +274,12 @@
"collectionPageTitle": "Colección",
"collectionPickPageTitle": "Elegir",
"collectionSelectionPageTitle": "{count, plural, =0{Seleccionar} =1{1 elemento} other{{count} elementos}}",
"collectionSelectPageTitle": "Seleccionar",
"collectionActionShowTitleSearch": "Mostrar filtros de títulos",
"collectionActionHideTitleSearch": "Ocultar filtros de títulos",
"collectionActionAddShortcut": "Agregar atajo",
"collectionActionEmptyBin": "Vaciar cesto",
"collectionActionCopy": "Copiar a álbum",
"collectionActionMove": "Mover a álbum",
"collectionActionRescan": "Volver a buscar",
@ -341,6 +358,8 @@
"tagPageTitle": "Etiquetas",
"tagEmpty": "Sin etiquetas",
"binPageTitle": "Cesto de basura",
"searchCollectionFieldHint": "Buscar en colección",
"searchSectionRecent": "Reciente",
"searchSectionAlbums": "Álbumes",
@ -366,6 +385,11 @@
"settingsKeepScreenOnTitle": "Mantener pantalla encendida",
"settingsDoubleBackExit": "Presione «atrás» dos veces para salir",
"settingsConfirmationDialogTile": "Diálogos de confirmación",
"settingsConfirmationDialogTitle": "Diálogos de confirmación",
"settingsConfirmationDialogDeleteItems": "Preguntar antes de eliminar elementos permanentemente",
"settingsConfirmationDialogMoveToBinItems": "Preguntar antes de mover elementos al cesto de basura",
"settingsNavigationDrawerTile": "Menú de navegación",
"settingsNavigationDrawerEditorTitle": "Menú de navegación",
"settingsNavigationDrawerBanner": "Toque y mantenga para mover y reordenar elementos del menú.",
@ -441,6 +465,8 @@
"settingsAllowInstalledAppAccessSubtitle": "Usado para mejorar los álbumes mostrados",
"settingsAllowErrorReporting": "Permitir reporte de errores anónimo",
"settingsSaveSearchHistory": "Guardar historial de búsqueda",
"settingsEnableBin": "Usar cesto de basura",
"settingsEnableBinSubtitle": "Guardar los elementos eliminados por 30 días",
"settingsHiddenItemsTile": "Elementos ocultos",
"settingsHiddenItemsTitle": "Elementos ocultos",

View file

@ -5,8 +5,9 @@
"welcomeTermsToggle": "Jaccepte les conditions dutilisation",
"itemCount": "{count, plural, =1{1 élément} other{{count} éléments}}",
"timeSeconds": "{seconds, plural, =1{1 seconde} other{{seconds} secondes}}",
"timeMinutes": "{minutes, plural, =1{1 minute} other{{minutes} minutes}}",
"timeSeconds": "{seconds, plural, =0{0 seconde} =1{1 seconde} other{{seconds} secondes}}",
"timeMinutes": "{minutes, plural, =0{0 minute} =1{1 minute} other{{minutes} minutes}}",
"timeDays": "{days, plural, =0{0 jour} =1{1 jour} other{{days} jours}}",
"focalLength": "{length} mm",
"applyButtonLabel": "ENREGISTRER",
@ -27,6 +28,7 @@
"resetButtonTooltip": "Réinitialiser",
"doubleBackExitMessage": "Pressez «\u00A0retour\u00A0» à nouveau pour quitter.",
"doNotAskAgain": "Ne pas demander de nouveau",
"sourceStateLoading": "Chargement",
"sourceStateCataloguing": "Classification",
@ -46,9 +48,10 @@
"entryActionCopyToClipboard": "Copier dans presse-papier",
"entryActionDelete": "Supprimer",
"entryActionConvert": "Convertir",
"entryActionExport": "Exporter",
"entryActionInfo": "Détails",
"entryActionRename": "Renommer",
"entryActionRestore": "Restaurer",
"entryActionRotateCCW": "Pivoter à gauche",
"entryActionRotateCW": "Pivoter à droite",
"entryActionFlip": "Retourner horizontalement",
@ -56,10 +59,10 @@
"entryActionShare": "Partager",
"entryActionViewSource": "Voir le code",
"entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo",
"entryActionEdit": "Modifier avec…",
"entryActionOpen": "Ouvrir avec",
"entryActionSetAs": "Utiliser comme",
"entryActionOpenMap": "Localiser avec",
"entryActionEdit": "Modifier",
"entryActionOpen": "Ouvrir avec",
"entryActionSetAs": "Utiliser comme",
"entryActionOpenMap": "Localiser avec",
"entryActionRotateScreen": "Pivoter lécran",
"entryActionAddFavourite": "Ajouter aux favoris",
"entryActionRemoveFavourite": "Retirer des favoris",
@ -79,6 +82,7 @@
"entryInfoActionEditTags": "Modifier les libellés",
"entryInfoActionRemoveMetadata": "Retirer les métadonnées",
"filterBinLabel": "Corbeille",
"filterFavouriteLabel": "Favori",
"filterLocationEmptyLabel": "Sans lieu",
"filterTagEmptyLabel": "Sans libellé",
@ -157,7 +161,8 @@
"noMatchingAppDialogTitle": "App indisponible",
"noMatchingAppDialogMessage": "Aucune app ne peut effectuer cette opération.",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet élément ?} other{Voulez-vous vraiment supprimer ces {count} éléments ?}}",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Mettre cet élément à la corbeille ?} other{Mettre ces {count} éléments à la corbeille ?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Supprimer cet élément ?} other{Supprimer ces {count} éléments ?}}",
"videoResumeDialogMessage": "Voulez-vous reprendre la lecture à {time} ?",
"videoStartOverButtonLabel": "RECOMMENCER",
@ -177,8 +182,8 @@
"renameAlbumDialogLabel": "Nouveau nom",
"renameAlbumDialogLabelAlreadyExistsHelper": "Le dossier existe déjà",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer cet album et son élément ?} other{Voulez-vous vraiment supprimer cet album et ses {count} éléments ?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Supprimer cet album et son élément ?} other{Supprimer cet album et ses {count} éléments ?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Supprimer ces albums et leur élément ?} other{Supprimer ces albums et leurs {count} éléments ?}}",
"exportEntryDialogFormat": "Format :",
"exportEntryDialogWidth": "Largeur",
@ -188,7 +193,7 @@
"editEntryDateDialogTitle": "Date & Heure",
"editEntryDateDialogSetCustom": "Régler une date personnalisée",
"editEntryDateDialogCopyField": "Copier d'une autre date",
"editEntryDateDialogCopyField": "Copier dune autre date",
"editEntryDateDialogExtractFromTitle": "Extraire du titre",
"editEntryDateDialogShift": "Décaler",
"editEntryDateDialogSourceFileModifiedDate": "Date de modification du fichier",
@ -267,11 +272,12 @@
"collectionPageTitle": "Collection",
"collectionPickPageTitle": "Sélection",
"collectionSelectionPageTitle": "{count, plural, =0{Sélection} =1{1 élément} other{{count} éléments}}",
"collectionSelectPageTitle": "Sélection",
"collectionActionShowTitleSearch": "Filtrer les titres",
"collectionActionHideTitleSearch": "Masquer le filtre",
"collectionActionAddShortcut": "Créer un raccourci",
"collectionActionEmptyBin": "Vider la corbeille",
"collectionActionCopy": "Copier vers lalbum",
"collectionActionMove": "Déplacer vers lalbum",
"collectionActionRescan": "Réanalyser",
@ -350,6 +356,8 @@
"tagPageTitle": "Libellés",
"tagEmpty": "Aucun libellé",
"binPageTitle": "Corbeille",
"searchCollectionFieldHint": "Recherche",
"searchSectionRecent": "Historique",
"searchSectionAlbums": "Albums",
@ -375,6 +383,11 @@
"settingsKeepScreenOnTitle": "Allumage de lécran",
"settingsDoubleBackExit": "Presser «\u00A0retour\u00A0» 2 fois pour quitter",
"settingsConfirmationDialogTile": "Demandes de confirmation",
"settingsConfirmationDialogTitle": "Demandes de confirmation",
"settingsConfirmationDialogDeleteItems": "Suppression définitive déléments",
"settingsConfirmationDialogMoveToBinItems": "Mise déléments à la corbeille",
"settingsNavigationDrawerTile": "Menu de navigation",
"settingsNavigationDrawerEditorTitle": "Menu de navigation",
"settingsNavigationDrawerBanner": "Maintenez votre doigt appuyé pour déplacer et réorganiser les éléments de menu.",
@ -450,6 +463,8 @@
"settingsAllowInstalledAppAccessSubtitle": "Pour un affichage amélioré des albums",
"settingsAllowErrorReporting": "Autoriser lenvoi de rapports derreur",
"settingsSaveSearchHistory": "Maintenir un historique des recherches",
"settingsEnableBin": "Utiliser la corbeille",
"settingsEnableBinSubtitle": "Conserver les éléments supprimés pendant 30 jours",
"settingsHiddenItemsTile": "Éléments masqués",
"settingsHiddenItemsTitle": "Éléments masqués",

560
lib/l10n/app_id.arb Normal file
View file

@ -0,0 +1,560 @@
{
"appName": "Aves",
"welcomeMessage": "Selamat datang ke Aves",
"welcomeOptional": "Opsional",
"welcomeTermsToggle": "Saya menyetujui syarat dan ketentuan",
"itemCount": "{count, plural, other{{count} benda}}",
"timeSeconds": "{seconds, plural, other{{seconds} detik}}",
"timeMinutes": "{minutes, plural, other{{minutes} menit}}",
"timeDays": "{days, plural, other{{days} hari}}",
"focalLength": "{length} mm",
"applyButtonLabel": "TERAPKAN",
"deleteButtonLabel": "BUANG",
"nextButtonLabel": "SELANJUTNYA",
"showButtonLabel": "TAMPILKAN",
"hideButtonLabel": "SEMBUNYIKAN",
"continueButtonLabel": "SELANJUTNYA",
"cancelTooltip": "Batalkan",
"changeTooltip": "Ganti",
"clearTooltip": "Hapus",
"previousTooltip": "Sebelumnya",
"nextTooltip": "Selanjutnya",
"showTooltip": "Tampilkan",
"hideTooltip": "Sembunyikan",
"actionRemove": "Hapus",
"resetButtonTooltip": "Ulang",
"doubleBackExitMessage": "Ketuk “kembali” lagi untuk keluar.",
"doNotAskAgain": "Jangan tanya lagi",
"sourceStateLoading": "Memuat",
"sourceStateCataloguing": "Mengkatalog",
"sourceStateLocatingCountries": "Mencari negara",
"sourceStateLocatingPlaces": "Mencari tempat",
"chipActionDelete": "Hapus",
"chipActionGoToAlbumPage": "Tampilkan di Album",
"chipActionGoToCountryPage": "Tampilkan di Negara",
"chipActionGoToTagPage": "Tampilkan di Tag",
"chipActionHide": "Sembunyikan",
"chipActionPin": "Sematkan ke atas",
"chipActionUnpin": "Lepas sematan dari atas",
"chipActionRename": "Ganti nama",
"chipActionSetCover": "Setel sampul",
"chipActionCreateAlbum": "Membuat album",
"entryActionCopyToClipboard": "Salinan ke papan",
"entryActionDelete": "Hapus",
"entryActionConvert": "Ubah",
"entryActionExport": "Ekspor",
"entryActionRename": "Ganti nama",
"entryActionRestore": "Pulihkan",
"entryActionRotateCCW": "Putar berlawanan arah jarum jam",
"entryActionRotateCW": "Putar searah jarum jam",
"entryActionFlip": "Balik secara horisontal",
"entryActionPrint": "Cetak",
"entryActionShare": "Bagikan",
"entryActionViewSource": "Lihat sumber",
"entryActionViewMotionPhotoVideo": "Buka Foto bergerak",
"entryActionEdit": "Ubah",
"entryActionOpen": "Buka dengan",
"entryActionSetAs": "Tetapkan sebagai",
"entryActionOpenMap": "Tampilkan di peta",
"entryActionRotateScreen": "Putar layar",
"entryActionAddFavourite": "Tambahkan ke favorit",
"entryActionRemoveFavourite": "Hapus dari favorit",
"videoActionCaptureFrame": "Tangkap bingkai",
"videoActionPause": "Henti",
"videoActionPlay": "Mainkan",
"videoActionReplay10": "Mundur 10 detik",
"videoActionSkip10": "Majukan 10 detik",
"videoActionSelectStreams": "Pilih trek",
"videoActionSetSpeed": "Kecepatan pemutaran",
"videoActionSettings": "Pengaturan",
"entryInfoActionEditDate": "Ubah tanggal & waktu",
"entryInfoActionEditLocation": "Ubah lokasi",
"entryInfoActionEditRating": "Ubah peringkat",
"entryInfoActionEditTags": "Ubah tag",
"entryInfoActionRemoveMetadata": "Hapus metadata",
"filterBinLabel": "Tong sampah",
"filterFavouriteLabel": "Favorit",
"filterLocationEmptyLabel": "Lokasi Tidak ditemukan",
"filterTagEmptyLabel": "Tidak ditag",
"filterRatingUnratedLabel": "Belum diberi peringkat",
"filterRatingRejectedLabel": "Ditolak",
"filterTypeAnimatedLabel": "Teranimasi",
"filterTypeMotionPhotoLabel": "Foto bergerak",
"filterTypePanoramaLabel": "Panorama",
"filterTypeRawLabel": "Raw",
"filterTypeSphericalVideoLabel": "Video 360°",
"filterTypeGeotiffLabel": "GeoTIFF",
"filterMimeImageLabel": "Gambar",
"filterMimeVideoLabel": "Video",
"coordinateFormatDms": "DMS",
"coordinateFormatDecimal": "Derajat desimal",
"coordinateDms": "{coordinate} {direction}",
"coordinateDmsNorth": "N",
"coordinateDmsSouth": "S",
"coordinateDmsEast": "E",
"coordinateDmsWest": "W",
"unitSystemMetric": "Metrik",
"unitSystemImperial": "Imperial",
"videoLoopModeNever": "Tidak pernah",
"videoLoopModeShortOnly": "Hanya video pendek",
"videoLoopModeAlways": "Selalu",
"mapStyleGoogleNormal": "Google Maps",
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
"mapStyleGoogleTerrain": "Google Maps (Terrain)",
"mapStyleOsmHot": "Humanitarian OSM",
"mapStyleStamenToner": "Stamen Toner",
"mapStyleStamenWatercolor": "Stamen Watercolor",
"nameConflictStrategyRename": "Ganti nama",
"nameConflictStrategyReplace": "Ganti",
"nameConflictStrategySkip": "Lewati",
"keepScreenOnNever": "Tidak pernah",
"keepScreenOnViewerOnly": "Hanya halaman pemirsa",
"keepScreenOnAlways": "Selalu",
"accessibilityAnimationsRemove": "Mencegah efek layar",
"accessibilityAnimationsKeep": "Simpan efek layar",
"albumTierNew": "Baru",
"albumTierPinned": "Disemat",
"albumTierSpecial": "Umum",
"albumTierApps": "Aplikasi",
"albumTierRegular": "Lainnya",
"storageVolumeDescriptionFallbackPrimary": "Penyimpanan internal",
"storageVolumeDescriptionFallbackNonPrimary": "kartu SD",
"rootDirectoryDescription": "direktori root",
"otherDirectoryDescription": "“{name}” direktori",
"storageAccessDialogTitle": "Akses Penyimpanan",
"storageAccessDialogMessage": "Silahkan pilih {directory} dari “{volume}” di layar berikutnya untuk memberikan akses aplikasi ini ke sana.",
"restrictedAccessDialogTitle": "Akses Terbatas",
"restrictedAccessDialogMessage": "Aplikasi ini tidak diizinkan untuk mengubah file di {directory} dari “{volume}”.\n\nSilahkan pakai aplikasi Manager File atau aplikasi gallery untuk gerakkan benda ke direktori lain.",
"notEnoughSpaceDialogTitle": "Tidak Cukup Ruang",
"notEnoughSpaceDialogMessage": "Operasi ini memerlukan {neededSize} ruang kosong di “{volume}” untuk menyelesaikan, tetapi hanya ada {freeSize} tersisa.",
"missingSystemFilePickerDialogTitle": "Pemilih File Sistem Tidak Ada",
"missingSystemFilePickerDialogMessage": "Pemilih file sistem tidak ada atau dinonaktifkan. Harap aktifkan dan coba lagi.",
"unsupportedTypeDialogTitle": "Jenis Yang Tidak Didukung",
"unsupportedTypeDialogMessage": "{count, plural, other{Operasi ini tidak didukung untuk benda dari jenis berikut: {types}.}}",
"nameConflictDialogSingleSourceMessage": "Beberapa file di folder tujuan memiliki nama yang sama.",
"nameConflictDialogMultipleSourceMessage": "Beberapa file memiliki nama yang sama.",
"addShortcutDialogLabel": "Label pintasan",
"addShortcutButtonLabel": "TAMBAH",
"noMatchingAppDialogTitle": "Tidak Ada Aplikasi Yang Cocok",
"noMatchingAppDialogMessage": "Tidak ada aplikasi yang cocok untuk menangani ini.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Pindah benda ini ke tong sampah?} other{Pindah {count} benda ke tempat sampah?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus benda ini?} other{Apakah Anda yakin ingin menghapus {count} benda?}}",
"videoResumeDialogMessage": "Apakah Anda ingin melanjutkan di {time}?",
"videoStartOverButtonLabel": "ULANG DARI AWAL",
"videoResumeButtonLabel": "LANJUT",
"setCoverDialogTitle": "Setel Sampul",
"setCoverDialogLatest": "Benda terbaru",
"setCoverDialogCustom": "Kustom",
"hideFilterConfirmationDialogMessage": "Foto dan video yang cocok akan disembunyikan dari koleksi Anda. Anda dapat menampilkannya lagi dari pengaturan “Privasi”.\n\nApakah Anda yakin ingin menyembunyikannya?",
"newAlbumDialogTitle": "Album Baru",
"newAlbumDialogNameLabel": "Nama album",
"newAlbumDialogNameLabelAlreadyExistsHelper": "Direktori sudah ada",
"newAlbumDialogStorageLabel": "Penyimpanan:",
"renameAlbumDialogLabel": "Nama baru",
"renameAlbumDialogLabelAlreadyExistsHelper": "Direktori sudah ada",
"deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Anda yakin ingin menghapus album ini dan bendanya?} other{Apakah Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Yakin ingin menghapus album ini dan bendanya?} other{Anda yakin ingin menghapus album ini dan {count} bendanya?}}",
"exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Lebar",
"exportEntryDialogHeight": "Tinggi",
"renameEntryDialogLabel": "Nama baru",
"editEntryDateDialogTitle": "Tanggal & Waktu",
"editEntryDateDialogSetCustom": "Atur tanggal khusus",
"editEntryDateDialogCopyField": "Salin dari tanggal lain",
"editEntryDateDialogExtractFromTitle": "Ekstrak dari judul",
"editEntryDateDialogShift": "Geser",
"editEntryDateDialogSourceFileModifiedDate": "Tanggal modifikasi file",
"editEntryDateDialogTargetFieldsHeader": "Bidang untuk dimodifikasikan",
"editEntryDateDialogHours": "Jam",
"editEntryDateDialogMinutes": "Menit",
"editEntryLocationDialogTitle": "Lokasi",
"editEntryLocationDialogChooseOnMapTooltip": "Pilih di peta",
"editEntryLocationDialogLatitude": "Garis lintang",
"editEntryLocationDialogLongitude": "Garis bujur",
"locationPickerUseThisLocationButton": "Gunakan lokasi ini",
"editEntryRatingDialogTitle": "Peringkat",
"removeEntryMetadataDialogTitle": "Penghapusan Metadata",
"removeEntryMetadataDialogMore": "Lebih Banyak",
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP diperlukan untuk memutar video di dalam Foto bergerak.\n\nAnda yakin ingin menghapusnya?",
"videoSpeedDialogLabel": "Kecepatan pemutaran",
"videoStreamSelectionDialogVideo": "Video",
"videoStreamSelectionDialogAudio": "Audio",
"videoStreamSelectionDialogText": "Subtitle",
"videoStreamSelectionDialogOff": "Mati",
"videoStreamSelectionDialogTrack": "Trek",
"videoStreamSelectionDialogNoSelection": "Tidak ada Trek yang lain.",
"genericSuccessFeedback": "Sukses!",
"genericFailureFeedback": "Gagal",
"menuActionConfigureView": "Lihat",
"menuActionSelect": "Pilih",
"menuActionSelectAll": "Pilih semua",
"menuActionSelectNone": "Pilih tidak ada",
"menuActionMap": "Peta",
"menuActionStats": "Statistik",
"viewDialogTabSort": "Sortir",
"viewDialogTabGroup": "Grup",
"viewDialogTabLayout": "Tata letak",
"tileLayoutGrid": "Grid",
"tileLayoutList": "Daftar",
"aboutPageTitle": "Tentang",
"aboutLinkSources": "Sumber",
"aboutLinkLicense": "Lisensi",
"aboutLinkPolicy": "Aturan Privasi",
"aboutBug": "Lapor Bug",
"aboutBugSaveLogInstruction": "Simpan log aplikasi ke file",
"aboutBugSaveLogButton": "Simpan",
"aboutBugCopyInfoInstruction": "Salin informasi sistem",
"aboutBugCopyInfoButton": "Salin",
"aboutBugReportInstruction": "Laporkan ke GitHub dengan log dan informasi sistem",
"aboutBugReportButton": "Rapor",
"aboutCredits": "Kredit",
"aboutCreditsWorldAtlas1": "Aplikasi ini menggunakan file TopoJSON dari",
"aboutCreditsWorldAtlas2": "dibawah Lisensi ISC.",
"aboutCreditsTranslators": "Penerjemah:",
"aboutCreditsTranslatorLine": "{language}: {names}",
"aboutLicenses": "Lisensi Sumber Terbuka",
"aboutLicensesBanner": "Aplikasi ini menggunakan paket dan pustaka sumber terbuka berikut.",
"aboutLicensesAndroidLibraries": "Perpustakaan Android",
"aboutLicensesFlutterPlugins": "Plugin Flutter",
"aboutLicensesFlutterPackages": "Paket Flutter",
"aboutLicensesDartPackages": "Paket Dart",
"aboutLicensesShowAllButtonLabel": "Tampilkan Semua Lisensi",
"policyPageTitle": "Aturan Privasi",
"collectionPageTitle": "Koleksi",
"collectionPickPageTitle": "Pilih",
"collectionSelectPageTitle": "Pilih benda",
"collectionActionShowTitleSearch": "Tampilkan filter judul",
"collectionActionHideTitleSearch": "Sembunyikan filter judul",
"collectionActionAddShortcut": "Tambahkan pintasan",
"collectionActionEmptyBin": "Kosongkan tong sampah",
"collectionActionCopy": "Salin ke album",
"collectionActionMove": "Pindah ke album",
"collectionActionRescan": "Pindai ulang",
"collectionActionEdit": "Ubah",
"collectionSearchTitlesHintText": "Cari judul",
"collectionSortDate": "Lewat tanggal",
"collectionSortSize": "Lewat ukuran",
"collectionSortName": "Lewat nama album & file",
"collectionSortRating": "Lewat peringkat",
"collectionGroupAlbum": "Lewat album",
"collectionGroupMonth": "Lewat bulan",
"collectionGroupDay": "Lewat hari",
"collectionGroupNone": "Jangan kelompokkan",
"sectionUnknown": "Tidak dikenal",
"dateToday": "Hari ini",
"dateYesterday": "Kemaren",
"dateThisMonth": "Bulan ini",
"collectionDeleteFailureFeedback": "{count, plural, other{Gagal untuk menghapus {count} benda}}",
"collectionCopyFailureFeedback": "{count, plural, other{Gagal untuk menyalin {count} benda}}",
"collectionMoveFailureFeedback": "{count, plural, other{Gagal untuk menggerakkan {count} benda}}",
"collectionEditFailureFeedback": "{count, plural, other{Gagal untuk mengubah {count} benda}}",
"collectionExportFailureFeedback": "{count, plural, other{Gagal untuk mengekspor {count} halaman}}",
"collectionCopySuccessFeedback": "{count, plural, other{Menyalin {count} benda}}",
"collectionMoveSuccessFeedback": "{count, plural, other{Menggerakkan {count} benda}}",
"collectionEditSuccessFeedback": "{count, plural, other{Mengubah {count} benda}}",
"collectionEmptyFavourites": "Tidak ada favorit",
"collectionEmptyVideos": "Tidak ada video",
"collectionEmptyImages": "Tidak ada gambar",
"collectionSelectSectionTooltip": "Pilih bagian",
"collectionDeselectSectionTooltip": "Batalkan pilihan bagian",
"drawerCollectionAll": "Semua koleksi",
"drawerCollectionFavourites": "Favorit",
"drawerCollectionImages": "Gambar",
"drawerCollectionVideos": "Video",
"drawerCollectionAnimated": "Teranimasi",
"drawerCollectionMotionPhotos": "Foto bergerak",
"drawerCollectionPanoramas": "Panorama",
"drawerCollectionRaws": "Foto Raw",
"drawerCollectionSphericalVideos": "Video 360°",
"chipSortDate": "Lewat tanggal",
"chipSortName": "Lewat nama",
"chipSortCount": "Lewat jumlah benda",
"albumGroupTier": "Lewat tingkat",
"albumGroupVolume": "Lewat volume penyimpanan",
"albumGroupNone": "Jangan kelompokkan",
"albumPickPageTitleCopy": "Salin ke Album",
"albumPickPageTitleExport": "Ekspor ke Album",
"albumPickPageTitleMove": "Pindah ke Album",
"albumPickPageTitlePick": "Pilih Album",
"albumCamera": "Kamera",
"albumDownload": "Download",
"albumScreenshots": "Tangkapan layar",
"albumScreenRecordings": "Rekaman layar",
"albumVideoCaptures": "Tangkapan Video",
"albumPageTitle": "Album",
"albumEmpty": "Tidak ada album",
"createAlbumTooltip": "Buat album",
"createAlbumButtonLabel": "BUAT",
"newFilterBanner": "baru",
"countryPageTitle": "Negara",
"countryEmpty": "Tidak ada negara",
"tagPageTitle": "Tag",
"tagEmpty": "Tidak ada tag",
"binPageTitle": "Tong Sampah",
"searchCollectionFieldHint": "Cari koleksi",
"searchSectionRecent": "Terkini",
"searchSectionAlbums": "Album",
"searchSectionCountries": "Negara",
"searchSectionPlaces": "Tempat",
"searchSectionTags": "Tag",
"searchSectionRating": "Peringkat",
"settingsPageTitle": "Pengaturan",
"settingsSystemDefault": "Sistem",
"settingsDefault": "Default",
"settingsActionExport": "Ekspor",
"settingsActionImport": "Impor",
"appExportCovers": "Sampul",
"appExportFavourites": "Favorit",
"appExportSettings": "Pengaturan",
"settingsSectionNavigation": "Navigasi",
"settingsHome": "Beranda",
"settingsKeepScreenOnTile": "Biarkan layarnya menyala",
"settingsKeepScreenOnTitle": "Biarkan Layarnya Menyala",
"settingsDoubleBackExit": "Ketuk “kembali” dua kali untuk keluar",
"settingsConfirmationDialogTile": "Dialog konfirmasi",
"settingsConfirmationDialogTitle": "Dialog Konfirmasi",
"settingsConfirmationDialogDeleteItems": "Tanya sebelum menghapus benda selamanya",
"settingsConfirmationDialogMoveToBinItems": "Tanya sebelum memindahkan benda ke tempat sampah",
"settingsNavigationDrawerTile": "Menu navigasi",
"settingsNavigationDrawerEditorTitle": "Menu Navigasi",
"settingsNavigationDrawerBanner": "Sentuh dan tahan untuk memindahkan dan menyusun ulang benda menu.",
"settingsNavigationDrawerTabTypes": "Tipe",
"settingsNavigationDrawerTabAlbums": "Album",
"settingsNavigationDrawerTabPages": "Halaman",
"settingsNavigationDrawerAddAlbum": "Tambahkan album",
"settingsSectionThumbnails": "Thumbnail",
"settingsThumbnailShowFavouriteIcon": "Tampilkan ikon favorit",
"settingsThumbnailShowLocationIcon": "Tampilkan ikon lokasi",
"settingsThumbnailShowMotionPhotoIcon": "Tampilkan ikon Foto bergerak",
"settingsThumbnailShowRating": "Tampilkan peringkat",
"settingsThumbnailShowRawIcon": "Tampilkan ikon raw",
"settingsThumbnailShowVideoDuration": "Tampilkan durasi video",
"settingsCollectionQuickActionsTile": "Aksi cepat",
"settingsCollectionQuickActionEditorTitle": "Aksi Cepat",
"settingsCollectionQuickActionTabBrowsing": "Menjelajah",
"settingsCollectionQuickActionTabSelecting": "Memilih",
"settingsCollectionBrowsingQuickActionEditorBanner": "Sentuh dan tahan untuk memindahkan tombol dan memilih tindakan yang ditampilkan saat menelusuri benda.",
"settingsCollectionSelectionQuickActionEditorBanner": "Sentuh dan tahan untuk memindahkan tombol dan memilih tindakan yang ditampilkan saat memilih benda.",
"settingsSectionViewer": "Penonton",
"settingsViewerUseCutout": "Gunakan area potongan",
"settingsViewerMaximumBrightness": "Kecerahan maksimum",
"settingsMotionPhotoAutoPlay": "Putar foto bergerak otomatis",
"settingsImageBackground": "Latar belakang gambar",
"settingsViewerQuickActionsTile": "Aksi cepat",
"settingsViewerQuickActionEditorTitle": "Aksi Cepat",
"settingsViewerQuickActionEditorBanner": "Sentuh dan tahan untuk memindahkan tombol dan memilih tindakan yang ditampilkan di penampil.",
"settingsViewerQuickActionEditorDisplayedButtons": "Tombol yang Ditampilkan",
"settingsViewerQuickActionEditorAvailableButtons": "Tombol yang tersedia",
"settingsViewerQuickActionEmpty": "Tidak ada tombol",
"settingsViewerOverlayTile": "Hamparan",
"settingsViewerOverlayTitle": "Hamparan",
"settingsViewerShowOverlayOnOpening": "Tampilkan saat pembukaan",
"settingsViewerShowMinimap": "Tampilkan minimap",
"settingsViewerShowInformation": "Tampilkan informasi",
"settingsViewerShowInformationSubtitle": "Tampilkan judul, tanggal, lokasi, dll.",
"settingsViewerShowShootingDetails": "Tampilkan detail pemotretan",
"settingsViewerEnableOverlayBlurEffect": "Efek Kabur",
"settingsVideoPageTitle": "Pengaturan Video",
"settingsSectionVideo": "Video",
"settingsVideoShowVideos": "Tampilkan video",
"settingsVideoEnableHardwareAcceleration": "Akselerasi perangkat keras",
"settingsVideoEnableAutoPlay": "Putar otomatis",
"settingsVideoLoopModeTile": "Putar ulang",
"settingsVideoLoopModeTitle": "Putar Ulang",
"settingsVideoQuickActionsTile": "Aksi cepat untuk video",
"settingsVideoQuickActionEditorTitle": "Aksi Cepat",
"settingsSubtitleThemeTile": "Subtitle",
"settingsSubtitleThemeTitle": "Subtitle",
"settingsSubtitleThemeSample": "Ini adalah sampel.",
"settingsSubtitleThemeTextAlignmentTile": "Perataan teks",
"settingsSubtitleThemeTextAlignmentTitle": "Perataan Teks",
"settingsSubtitleThemeTextSize": "Ukuran teks",
"settingsSubtitleThemeShowOutline": "Tampilkan garis besar dan bayangan",
"settingsSubtitleThemeTextColor": "Warna teks",
"settingsSubtitleThemeTextOpacity": "Opasitas teks",
"settingsSubtitleThemeBackgroundColor": "Warna latar belakang",
"settingsSubtitleThemeBackgroundOpacity": "Opasitas latar belakang",
"settingsSubtitleThemeTextAlignmentLeft": "Kiri",
"settingsSubtitleThemeTextAlignmentCenter": "Tengah",
"settingsSubtitleThemeTextAlignmentRight": "Kanan",
"settingsSectionPrivacy": "Privasi",
"settingsAllowInstalledAppAccess": "Izinkan akses ke inventori aplikasi",
"settingsAllowInstalledAppAccessSubtitle": "Digunakan untuk meningkatkan tampilan album",
"settingsAllowErrorReporting": "Izinkan pelaporan kesalahan anonim",
"settingsSaveSearchHistory": "Simpan riwayat pencarian",
"settingsEnableBin": "Gunakan tong sampah",
"settingsEnableBinSubtitle": "Simpan benda yang dihapus selama 30 hari",
"settingsHiddenItemsTile": "Benda tersembunyi",
"settingsHiddenItemsTitle": "Benda Tersembunyi",
"settingsHiddenFiltersTitle": "Filter Tersembunyi",
"settingsHiddenFiltersBanner": "Foto dan video filter tersembunyi yang cocok tidak akan muncul di koleksi Anda.",
"settingsHiddenFiltersEmpty": "Tidak ada filter tersembunyi",
"settingsHiddenPathsTitle": "Jalan Tersembunyi",
"settingsHiddenPathsBanner": "Foto dan video di folder ini, atau subfoldernya, tidak akan muncul di koleksi Anda.",
"addPathTooltip": "Tambahkan jalan",
"settingsStorageAccessTile": "Akses penyimpanan",
"settingsStorageAccessTitle": "Akses Penyimpanan",
"settingsStorageAccessBanner": "Beberapa direktori memerlukan pemberian akses eksplisit untuk memodifikasi file di dalamnya. Anda dapat meninjau di sini direktori yang sebelumnya Anda beri akses.",
"settingsStorageAccessEmpty": "Tidak ada akses",
"settingsStorageAccessRevokeTooltip": "Tarik kembali",
"settingsSectionAccessibility": "Aksesibilitas",
"settingsRemoveAnimationsTile": "Hapus animasi",
"settingsRemoveAnimationsTitle": "Hapus Animasi",
"settingsTimeToTakeActionTile": "Saatnya untuk mengambil tindakan",
"settingsTimeToTakeActionTitle": "Saatnya Bertindak",
"settingsSectionLanguage": "Bahasa & Format",
"settingsLanguage": "Bahasa",
"settingsCoordinateFormatTile": "Format koordinat",
"settingsCoordinateFormatTitle": "Format Koordinat",
"settingsUnitSystemTile": "Unit",
"settingsUnitSystemTitle": "Unit",
"statsPageTitle": "Statistik",
"statsWithGps": "{count, plural, other{{count} benda dengan lokasi}}",
"statsTopCountries": "Negara Teratas",
"statsTopPlaces": "Tempat Teratas",
"statsTopTags": "Tag Teratas",
"viewerOpenPanoramaButtonLabel": "BUKA PANORAMA",
"viewerErrorUnknown": "Ups!",
"viewerErrorDoesNotExist": "File tidak ada lagi.",
"viewerInfoPageTitle": "Info",
"viewerInfoBackToViewerTooltip": "Kembali ke pemirsa",
"viewerInfoUnknown": "tidak dikenal",
"viewerInfoLabelTitle": "Judul",
"viewerInfoLabelDate": "Tanggal",
"viewerInfoLabelResolution": "Resolusi",
"viewerInfoLabelSize": "Ukuran",
"viewerInfoLabelUri": "URI",
"viewerInfoLabelPath": "Jalan",
"viewerInfoLabelDuration": "Durasi",
"viewerInfoLabelOwner": "Dimiliki oleh",
"viewerInfoLabelCoordinates": "Koordinat",
"viewerInfoLabelAddress": "Alamat",
"mapStyleTitle": "Gaya Peta",
"mapStyleTooltip": "Pilih gaya peta",
"mapZoomInTooltip": "Perbesar",
"mapZoomOutTooltip": "Perkecil",
"mapPointNorthUpTooltip": "Arahkan ke utara ke atas",
"mapAttributionOsmHot": "Data peta © [OpenStreetMap](https://www.openstreetmap.org/copyright) kontributor • Tile oleh [HOT](https://www.hotosm.org/) • Diselenggarakan oleh [OSM France](https://openstreetmap.fr/)",
"mapAttributionStamen": "Data peta © [OpenStreetMap](https://www.openstreetmap.org/copyright) kontributor • Tile oleh [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0)",
"openMapPageTooltip": "Lihat di halaman Peta",
"mapEmptyRegion": "Tidak ada gambar di wilayah ini",
"viewerInfoOpenEmbeddedFailureFeedback": "Gagal mengekstrak data yang disematkan",
"viewerInfoOpenLinkText": "Buka",
"viewerInfoViewXmlLinkText": "Tampilkan XML",
"viewerInfoSearchFieldLabel": "Cari metadata",
"viewerInfoSearchEmpty": "Tidak ada kata kunci yang cocok",
"viewerInfoSearchSuggestionDate": "Tanggal & waktu",
"viewerInfoSearchSuggestionDescription": "Deskripsi",
"viewerInfoSearchSuggestionDimensions": "Dimensi",
"viewerInfoSearchSuggestionResolution": "Resolusi",
"viewerInfoSearchSuggestionRights": "Hak",
"tagEditorPageTitle": "Ubah Tag",
"tagEditorPageNewTagFieldLabel": "Tag baru",
"tagEditorPageAddTagTooltip": "Tambah tag",
"tagEditorSectionRecent": "Terkini",
"panoramaEnableSensorControl": "Aktifkan kontrol sensor",
"panoramaDisableSensorControl": "Nonaktifkan kontrol sensor",
"sourceViewerPageTitle": "Sumber",
"filePickerShowHiddenFiles": "Tampilkan file tersembunyi",
"filePickerDoNotShowHiddenFiles": "Jangan tampilkan file tersembunyi",
"filePickerOpenFrom": "Buka dari",
"filePickerNoItems": "Tidak ada benda",
"filePickerUseThisFolder": "Gunakan folder ini"
}

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, other{{seconds}초}}",
"timeMinutes": "{minutes, plural, other{{minutes}분}}",
"timeDays": "{days, plural, other{{days}일}}",
"focalLength": "{length} mm",
"applyButtonLabel": "확인",
@ -27,6 +28,7 @@
"resetButtonTooltip": "복원",
"doubleBackExitMessage": "종료하려면 한번 더 누르세요.",
"doNotAskAgain": "다시 묻지 않기",
"sourceStateLoading": "로딩 중",
"sourceStateCataloguing": "분석 중",
@ -46,9 +48,10 @@
"entryActionCopyToClipboard": "클립보드에 복사",
"entryActionDelete": "삭제",
"entryActionConvert": "변환",
"entryActionExport": "내보내기",
"entryActionInfo": "상세정보",
"entryActionRename": "이름 변경",
"entryActionRestore": "복원",
"entryActionRotateCCW": "좌회전",
"entryActionRotateCW": "우회전",
"entryActionFlip": "좌우 뒤집기",
@ -56,10 +59,10 @@
"entryActionShare": "공유",
"entryActionViewSource": "소스 코드 보기",
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
"entryActionEdit": "편집",
"entryActionOpen": "다른 앱에서 열기",
"entryActionSetAs": "다음 용도로 사용",
"entryActionOpenMap": "지도 앱에서 보기",
"entryActionEdit": "편집",
"entryActionOpen": "다른 앱에서 열기",
"entryActionSetAs": "다음 용도로 사용",
"entryActionOpenMap": "지도 앱에서 보기",
"entryActionRotateScreen": "화면 회전",
"entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",
@ -79,6 +82,7 @@
"entryInfoActionEditTags": "태그 수정",
"entryInfoActionRemoveMetadata": "메타데이터 삭제",
"filterBinLabel": "휴지통",
"filterFavouriteLabel": "즐겨찾기",
"filterLocationEmptyLabel": "장소 없음",
"filterTagEmptyLabel": "태그 없음",
@ -157,6 +161,7 @@
"noMatchingAppDialogTitle": "처리할 앱 없음",
"noMatchingAppDialogMessage": "이 작업을 처리할 수 있는 앱이 없습니다.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 휴지통으로 이동하시겠습니까?} other{항목 {count}개를 휴지통으로 이동하시겠습니까?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{이 항목을 삭제하시겠습니까?} other{항목 {count}개를 삭제하시겠습니까?}}",
"videoResumeDialogMessage": "{time}부터 재개하시겠습니까?",
@ -267,11 +272,12 @@
"collectionPageTitle": "미디어",
"collectionPickPageTitle": "항목 선택",
"collectionSelectionPageTitle": "{count, plural, =0{항목 선택} other{{count}개}}",
"collectionSelectPageTitle": "항목 선택",
"collectionActionShowTitleSearch": "제목 필터 보기",
"collectionActionHideTitleSearch": "제목 필터 숨기기",
"collectionActionAddShortcut": "홈 화면에 추가",
"collectionActionEmptyBin": "휴지통 비우기",
"collectionActionCopy": "앨범으로 복사",
"collectionActionMove": "앨범으로 이동",
"collectionActionRescan": "새로 분석",
@ -350,6 +356,8 @@
"tagPageTitle": "태그",
"tagEmpty": "태그가 없습니다",
"binPageTitle": "휴지통",
"searchCollectionFieldHint": "미디어 검색",
"searchSectionRecent": "최근 검색기록",
"searchSectionAlbums": "앨범",
@ -375,6 +383,11 @@
"settingsKeepScreenOnTitle": "화면 자동 꺼짐 방지",
"settingsDoubleBackExit": "뒤로가기 두번 눌러 앱 종료하기",
"settingsConfirmationDialogTile": "확정 대화상자",
"settingsConfirmationDialogTitle": "확정 대화상자",
"settingsConfirmationDialogDeleteItems": "항목을 완전히 삭제 시",
"settingsConfirmationDialogMoveToBinItems": "항목을 휴지통으로 이동 시",
"settingsNavigationDrawerTile": "탐색 메뉴",
"settingsNavigationDrawerEditorTitle": "탐색 메뉴",
"settingsNavigationDrawerBanner": "항목을 길게 누른 후 이동하여 탐색 메뉴에 표시될 항목의 순서를 수정하세요.",
@ -450,6 +463,8 @@
"settingsAllowInstalledAppAccessSubtitle": "앨범 표시 개선을 위해",
"settingsAllowErrorReporting": "오류 보고서 보내기",
"settingsSaveSearchHistory": "검색기록",
"settingsEnableBin": "휴지통 사용",
"settingsEnableBinSubtitle": "삭제한 항목을 30일 동안 보관하기",
"settingsHiddenItemsTile": "숨겨진 항목",
"settingsHiddenItemsTitle": "숨겨진 항목",

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 segundo} other{{seconds} segundos}}",
"timeMinutes": "{minutes, plural, =1{1 minuto} other{{minutes} minutos}}",
"timeDays": "{days, plural, =1{1 dia} other{{days} dias}}",
"focalLength": "{length} mm",
"applyButtonLabel": "APLIQUE",
@ -27,6 +28,7 @@
"resetButtonTooltip": "Resetar",
"doubleBackExitMessage": "Toque em “voltar” novamente para sair.",
"doNotAskAgain": "Não pergunte novamente",
"sourceStateLoading": "Carregando",
"sourceStateCataloguing": "Catalogação",
@ -47,8 +49,9 @@
"entryActionCopyToClipboard": "Copiar para área de transferência",
"entryActionDelete": "Excluir",
"entryActionExport": "Exportar",
"entryActionInfo": "Informações",
"entryActionConvert": "Converter",
"entryActionRename": "Renomear",
"entryActionRestore": "Restaurar",
"entryActionRotateCCW": "Rotacionar para esquerda",
"entryActionRotateCW": "Rotacionar para direita",
"entryActionFlip": "Virar horizontalmente",
@ -56,10 +59,10 @@
"entryActionShare": "Compartilhado",
"entryActionViewSource": "Ver fonte",
"entryActionViewMotionPhotoVideo": "Abrir foto em movimento",
"entryActionEdit": "Editar com…",
"entryActionOpen": "Abrir com",
"entryActionSetAs": "Definir como",
"entryActionOpenMap": "Mostrar no aplicativo de mapa",
"entryActionEdit": "Editar",
"entryActionOpen": "Abrir com",
"entryActionSetAs": "Definir como",
"entryActionOpenMap": "Mostrar no aplicativo de mapa",
"entryActionRotateScreen": "Girar a tela",
"entryActionAddFavourite": "Adicionar aos favoritos",
"entryActionRemoveFavourite": "Remova dos favoritos",
@ -79,6 +82,7 @@
"entryInfoActionEditTags": "Editar etiquetas",
"entryInfoActionRemoveMetadata": "Remover metadados",
"filterBinLabel": "Lixeira",
"filterFavouriteLabel": "Favorito",
"filterLocationEmptyLabel": "Não localizado",
"filterTagEmptyLabel": "Sem etiqueta",
@ -157,6 +161,7 @@
"noMatchingAppDialogTitle": "Nenhum aplicativo correspondente",
"noMatchingAppDialogMessage": "Não há aplicativos que possam lidar com isso.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Mover esse item para a lixeira?} other{Mova estes {count} itens para a lixeira?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Tem certeza de que deseja excluir este item?} other{Tem certeza de que deseja excluir estes {count} itens?}}",
"videoResumeDialogMessage": "Deseja continuar jogando em {time}?",
@ -267,11 +272,12 @@
"collectionPageTitle": "Coleção",
"collectionPickPageTitle": "Escolher",
"collectionSelectionPageTitle": "{count, plural, =0{Selecionar itens} =1{1 item} other{{count} itens}}",
"collectionSelectPageTitle": "Selecionar itens",
"collectionActionShowTitleSearch": "Mostrar filtro de título",
"collectionActionHideTitleSearch": "Ocultar filtro de título",
"collectionActionAddShortcut": "Adicionar atalho",
"collectionActionEmptyBin": "Caixa vazia",
"collectionActionCopy": "Copiar para o álbum",
"collectionActionMove": "Mover para o álbum",
"collectionActionRescan": "Reexaminar",
@ -350,6 +356,8 @@
"tagPageTitle": "Etiquetas",
"tagEmpty": "Sem etiquetas",
"binPageTitle": "Lixeira",
"searchCollectionFieldHint": "Pesquisar coleção",
"searchSectionRecent": "Recente",
"searchSectionAlbums": "Álbuns",
@ -375,6 +383,11 @@
"settingsKeepScreenOnTitle": "Manter a tela ligada",
"settingsDoubleBackExit": "Toque em “voltar” duas vezes para sair",
"settingsConfirmationDialogTile": "Caixas de diálogo de confirmação",
"settingsConfirmationDialogTitle": "Caixas de diálogo de confirmação",
"settingsConfirmationDialogDeleteItems": "Pergunte antes de excluir itens para sempre",
"settingsConfirmationDialogMoveToBinItems": "Pergunte antes de mover itens para a lixeira",
"settingsNavigationDrawerTile": "Menu de navegação",
"settingsNavigationDrawerEditorTitle": "Menu de navegação",
"settingsNavigationDrawerBanner": "Toque e segure para mover e reordenar os itens do menu.",
@ -450,6 +463,8 @@
"settingsAllowInstalledAppAccessSubtitle": "Usado para melhorar a exibição do álbum",
"settingsAllowErrorReporting": "Permitir relatórios de erros anônimos",
"settingsSaveSearchHistory": "Salvar histórico de pesquisa",
"settingsEnableBin": "Usar lixeira",
"settingsEnableBinSubtitle": "Manter itens excluídos por 30 dias",
"settingsHiddenItemsTile": "Itens ocultos",
"settingsHiddenItemsTitle": "Itens ocultos",

View file

@ -7,6 +7,7 @@
"timeSeconds": "{seconds, plural, =1{1 секунда} few{{seconds} секунды} other{{seconds} секунд}}",
"timeMinutes": "{minutes, plural, =1{1 минута} few{{minutes} минуты} other{{minutes} минут}}",
"timeDays": "{days, plural, =1{1 день} few{{days} дня} other{{days} дней}}",
"focalLength": "{length} mm",
"applyButtonLabel": "ПРИМЕНИТЬ",
@ -27,6 +28,7 @@
"resetButtonTooltip": "Сбросить",
"doubleBackExitMessage": "Нажмите «назад» еще раз, чтобы выйти.",
"doNotAskAgain": "Больше не спрашивать",
"sourceStateLoading": "Загрузка",
"sourceStateCataloguing": "Каталогизация",
@ -47,8 +49,8 @@
"entryActionCopyToClipboard": "Скопировать в буфер обмена",
"entryActionDelete": "Удалить",
"entryActionExport": "Экспорт",
"entryActionInfo": "Информация",
"entryActionRename": "Переименовать",
"entryActionRestore": "Восстановить",
"entryActionRotateCCW": "Повернуть против часовой стрелки",
"entryActionRotateCW": "Повернуть по часовой стрелки",
"entryActionFlip": "Отразить по горизонтали",
@ -56,10 +58,10 @@
"entryActionShare": "Поделиться",
"entryActionViewSource": "Посмотреть источник",
"entryActionViewMotionPhotoVideo": "Открыть «Живые фото»",
"entryActionEdit": "Изменить с помощью…",
"entryActionOpen": "Открыть с помощью",
"entryActionSetAs": "Установить как",
"entryActionOpenMap": "Показать на карте",
"entryActionEdit": "Изменить",
"entryActionOpen": "Открыть с помощью",
"entryActionSetAs": "Установить как",
"entryActionOpenMap": "Показать на карте",
"entryActionRotateScreen": "Повернуть экран",
"entryActionAddFavourite": "Добавить в избранное",
"entryActionRemoveFavourite": "Удалить из избранного",
@ -79,6 +81,7 @@
"entryInfoActionEditTags": "Изменить теги",
"entryInfoActionRemoveMetadata": "Удалить метаданные",
"filterBinLabel": "Корзина",
"filterFavouriteLabel": "Избранное",
"filterLocationEmptyLabel": "Без местоположения",
"filterTagEmptyLabel": "Без тегов",
@ -157,6 +160,7 @@
"noMatchingAppDialogTitle": "Нет подходящего приложения",
"noMatchingAppDialogMessage": "Нет приложений, которые могли бы с этим справиться.",
"binEntriesConfirmationDialogMessage": "{count, plural, =1{Переместить этот элемент в корзину?} few{Переместить эти {count} элемента в корзину?} other{Переместить эти {count} элементов в корзину?}}",
"deleteEntriesConfirmationDialogMessage": "{count, plural, =1{Вы уверены, что хотите удалить этот объект?} few{Вы уверены, что хотите удалить эти {count} объекта?} other{Вы уверены, что хотите удалить эти {count} объектов?}}",
"videoResumeDialogMessage": "Хотите ли вы возобновить воспроизведение на {time}?",
@ -267,11 +271,12 @@
"collectionPageTitle": "Коллекция",
"collectionPickPageTitle": "Выбрать",
"collectionSelectionPageTitle": "{count, plural, =0{Выберите объекты} =1{1 объект} few{{count} объекта} other{{count} объектов}}",
"collectionSelectPageTitle": "Выберите объекты",
"collectionActionShowTitleSearch": "Показать фильтр заголовка",
"collectionActionHideTitleSearch": "Скрыть фильтр заголовка",
"collectionActionAddShortcut": "Добавить ярлык",
"collectionActionEmptyBin": "Очистить корзину",
"collectionActionCopy": "Скопировать в альбом",
"collectionActionMove": "Переместить в альбом",
"collectionActionRescan": "Пересканировать",
@ -350,6 +355,8 @@
"tagPageTitle": "Теги",
"tagEmpty": "Нет тегов",
"binPageTitle": "Корзина",
"searchCollectionFieldHint": "Поиск по коллекции",
"searchSectionRecent": "Недавние",
"searchSectionAlbums": "Альбомы",
@ -375,6 +382,11 @@
"settingsKeepScreenOnTitle": "Держать экран включенным",
"settingsDoubleBackExit": "Дважды нажмите «Назад», чтобы выйти",
"settingsConfirmationDialogTile": "Диалоги подтверждения",
"settingsConfirmationDialogTitle": "Диалоги подтверждения",
"settingsConfirmationDialogDeleteItems": "Спросить, прежде чем удалять элементы навсегда",
"settingsConfirmationDialogMoveToBinItems": "Спросить, прежде чем перемещать элементы в корзину",
"settingsNavigationDrawerTile": "Навигационное меню",
"settingsNavigationDrawerEditorTitle": "Навигационное меню",
"settingsNavigationDrawerBanner": "Нажмите и удерживайте, чтобы переместить и изменить порядок пунктов меню.",
@ -450,6 +462,8 @@
"settingsAllowInstalledAppAccessSubtitle": "Используется для улучшения отображения альбомов",
"settingsAllowErrorReporting": "Разрешить анонимную отправку логов",
"settingsSaveSearchHistory": "Сохранять историю поиска",
"settingsEnableBin": "Использовать корзину",
"settingsEnableBinSubtitle": "Хранить удалённые элементы в течение 30 дней",
"settingsHiddenItemsTile": "Скрытые объекты",
"settingsHiddenItemsTitle": "Скрытые объекты",

View file

@ -1,5 +1,5 @@
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/theme/themes.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
@ -7,10 +7,12 @@ enum EntryAction {
addShortcut,
copyToClipboard,
delete,
export,
info,
restore,
convert,
print,
rename,
copy,
move,
share,
toggleFavourite,
// raster
@ -31,25 +33,32 @@ enum EntryAction {
}
class EntryActions {
static const inApp = [
EntryAction.info,
EntryAction.toggleFavourite,
static const topLevel = [
EntryAction.share,
EntryAction.delete,
EntryAction.edit,
EntryAction.rename,
EntryAction.export,
EntryAction.addShortcut,
EntryAction.copyToClipboard,
EntryAction.print,
EntryAction.delete,
EntryAction.copy,
EntryAction.move,
EntryAction.toggleFavourite,
EntryAction.viewSource,
EntryAction.rotateScreen,
];
static const externalApp = [
EntryAction.edit,
static const export = [
EntryAction.convert,
EntryAction.addShortcut,
EntryAction.copyToClipboard,
EntryAction.print,
EntryAction.open,
EntryAction.setAs,
EntryAction.openMap,
EntryAction.setAs,
];
static const exportExternal = [
EntryAction.open,
EntryAction.openMap,
EntryAction.setAs,
];
static const pageActions = [
@ -57,6 +66,12 @@ class EntryActions {
EntryAction.rotateCW,
EntryAction.flip,
];
static const trashed = [
EntryAction.delete,
EntryAction.restore,
EntryAction.debug,
];
}
extension ExtraEntryAction on EntryAction {
@ -68,14 +83,18 @@ extension ExtraEntryAction on EntryAction {
return context.l10n.entryActionCopyToClipboard;
case EntryAction.delete:
return context.l10n.entryActionDelete;
case EntryAction.export:
return context.l10n.entryActionExport;
case EntryAction.info:
return context.l10n.entryActionInfo;
case EntryAction.restore:
return context.l10n.entryActionRestore;
case EntryAction.convert:
return context.l10n.entryActionConvert;
case EntryAction.print:
return context.l10n.entryActionPrint;
case EntryAction.rename:
return context.l10n.entryActionRename;
case EntryAction.copy:
return context.l10n.collectionActionCopy;
case EntryAction.move:
return context.l10n.collectionActionMove;
case EntryAction.share:
return context.l10n.entryActionShare;
case EntryAction.toggleFavourite:
@ -109,15 +128,12 @@ extension ExtraEntryAction on EntryAction {
}
}
Widget? getIcon() {
final icon = getIconData();
if (icon == null) return null;
final child = Icon(icon);
Widget getIcon() {
final child = Icon(getIconData());
switch (this) {
case EntryAction.debug:
return ShaderMask(
shaderCallback: Themes.debugGradient.createShader,
shaderCallback: AColors.debugGradient.createShader,
child: child,
);
default:
@ -125,7 +141,7 @@ extension ExtraEntryAction on EntryAction {
}
}
IconData? getIconData() {
IconData getIconData() {
switch (this) {
case EntryAction.addShortcut:
return AIcons.addShortcut;
@ -133,14 +149,18 @@ extension ExtraEntryAction on EntryAction {
return AIcons.clipboard;
case EntryAction.delete:
return AIcons.delete;
case EntryAction.export:
return AIcons.saveAs;
case EntryAction.info:
return AIcons.info;
case EntryAction.restore:
return AIcons.restore;
case EntryAction.convert:
return AIcons.convert;
case EntryAction.print:
return AIcons.print;
case EntryAction.rename:
return AIcons.rename;
case EntryAction.copy:
return AIcons.copy;
case EntryAction.move:
return AIcons.move;
case EntryAction.share:
return AIcons.share;
case EntryAction.toggleFavourite:
@ -158,10 +178,13 @@ extension ExtraEntryAction on EntryAction {
return AIcons.vector;
// external
case EntryAction.edit:
return AIcons.edit;
case EntryAction.open:
return AIcons.openOutside;
case EntryAction.openMap:
return AIcons.map;
case EntryAction.setAs:
return null;
return AIcons.setAs;
// platform
case EntryAction.rotateScreen:
return AIcons.rotateScreen;

View file

@ -1,3 +1,4 @@
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
@ -11,6 +12,8 @@ enum EntryInfoAction {
removeMetadata,
// motion photo
viewMotionPhotoVideo,
// debug
debug,
}
class EntryInfoActions {
@ -41,11 +44,23 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// motion photo
case EntryInfoAction.viewMotionPhotoVideo:
return context.l10n.entryActionViewMotionPhotoVideo;
// debug
case EntryInfoAction.debug:
return 'Debug';
}
}
Widget getIcon() {
return Icon(_getIconData());
final child = Icon(_getIconData());
switch (this) {
case EntryInfoAction.debug:
return ShaderMask(
shaderCallback: AColors.debugGradient.createShader,
child: child,
);
default:
return child;
}
}
IconData _getIconData() {
@ -64,6 +79,9 @@ extension ExtraEntryInfoAction on EntryInfoAction {
// motion photo
case EntryInfoAction.viewMotionPhotoVideo:
return AIcons.motionPhoto;
// debug
case EntryInfoAction.debug:
return AIcons.debug;
}
}
}

View file

@ -12,6 +12,7 @@ enum EntrySetAction {
searchCollection,
toggleTitleSearch,
addShortcut,
emptyBin,
// browsing or selecting
map,
stats,
@ -19,6 +20,7 @@ enum EntrySetAction {
// selecting
share,
delete,
restore,
copy,
move,
toggleFavourite,
@ -40,7 +42,18 @@ class EntrySetActions {
EntrySetAction.selectNone,
];
static const browsing = [
static const pageBrowsing = [
EntrySetAction.searchCollection,
EntrySetAction.toggleTitleSearch,
EntrySetAction.addShortcut,
EntrySetAction.map,
EntrySetAction.stats,
EntrySetAction.rescan,
EntrySetAction.emptyBin,
];
// exclude bin related actions
static const collectionEditorBrowsing = [
EntrySetAction.searchCollection,
EntrySetAction.toggleTitleSearch,
EntrySetAction.addShortcut,
@ -49,7 +62,21 @@ class EntrySetActions {
EntrySetAction.rescan,
];
static const selection = [
static const pageSelection = [
EntrySetAction.share,
EntrySetAction.delete,
EntrySetAction.restore,
EntrySetAction.copy,
EntrySetAction.move,
EntrySetAction.toggleFavourite,
EntrySetAction.map,
EntrySetAction.stats,
EntrySetAction.rescan,
// editing actions are in their subsection
];
// exclude bin related actions
static const collectionEditorSelection = [
EntrySetAction.share,
EntrySetAction.delete,
EntrySetAction.copy,
@ -60,6 +87,14 @@ class EntrySetActions {
EntrySetAction.rescan,
// editing actions are in their subsection
];
static const edit = [
EntrySetAction.editDate,
EntrySetAction.editLocation,
EntrySetAction.editRating,
EntrySetAction.editTags,
EntrySetAction.removeMetadata,
];
}
extension ExtraEntrySetAction on EntrySetAction {
@ -82,6 +117,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.collectionActionShowTitleSearch;
case EntrySetAction.addShortcut:
return context.l10n.collectionActionAddShortcut;
case EntrySetAction.emptyBin:
return context.l10n.collectionActionEmptyBin;
// browsing or selecting
case EntrySetAction.map:
return context.l10n.menuActionMap;
@ -94,6 +131,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return context.l10n.entryActionShare;
case EntrySetAction.delete:
return context.l10n.entryActionDelete;
case EntrySetAction.restore:
return context.l10n.entryActionRestore;
case EntrySetAction.copy:
return context.l10n.collectionActionCopy;
case EntrySetAction.move:
@ -143,6 +182,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.filter;
case EntrySetAction.addShortcut:
return AIcons.addShortcut;
case EntrySetAction.emptyBin:
return AIcons.emptyBin;
// browsing or selecting
case EntrySetAction.map:
return AIcons.map;
@ -155,6 +196,8 @@ extension ExtraEntrySetAction on EntrySetAction {
return AIcons.share;
case EntrySetAction.delete:
return AIcons.delete;
case EntrySetAction.restore:
return AIcons.restore;
case EntrySetAction.copy:
return AIcons.copy;
case EntrySetAction.move:

View file

@ -1 +1 @@
enum MoveType { copy, move, export }
enum MoveType { copy, move, export, toBin, fromBin }

View file

@ -23,19 +23,19 @@ class Covers with ChangeNotifier {
Set<CoverRow> get all => Set.unmodifiable(_rows);
int? coverContentId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.contentId;
int? coverEntryId(CollectionFilter filter) => _rows.firstWhereOrNull((row) => row.filter == filter)?.entryId;
Future<void> set(CollectionFilter filter, int? contentId) async {
Future<void> set(CollectionFilter filter, int? entryId) async {
// erase contextual properties from filters before saving them
if (filter is AlbumFilter) {
filter = AlbumFilter(filter.album, null);
}
_rows.removeWhere((row) => row.filter == filter);
if (contentId == null) {
if (entryId == null) {
await metadataDb.removeCovers({filter});
} else {
final row = CoverRow(filter: filter, contentId: contentId);
final row = CoverRow(filter: filter, entryId: entryId);
_rows.add(row);
await metadataDb.addCovers({row});
}
@ -43,28 +43,26 @@ class Covers with ChangeNotifier {
notifyListeners();
}
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
final oldRows = _rows.where((row) => row.contentId == oldContentId).toSet();
if (oldRows.isEmpty) return;
for (final oldRow in oldRows) {
final filter = oldRow.filter;
_rows.remove(oldRow);
if (filter.test(entry)) {
final newRow = CoverRow(filter: filter, contentId: entry.contentId!);
await metadataDb.updateCoverEntryId(oldRow.contentId, newRow);
_rows.add(newRow);
} else {
await metadataDb.removeCovers({filter});
Future<void> moveEntry(AvesEntry entry, {required bool persist}) async {
final entryId = entry.id;
final rows = _rows.where((row) => row.entryId == entryId).toSet();
for (final row in rows) {
final filter = row.filter;
if (!filter.test(entry)) {
_rows.remove(row);
if (persist) {
await metadataDb.removeCovers({filter});
}
}
}
notifyListeners();
}
Future<void> removeEntries(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
Future<void> removeIds(Set<int> entryIds) async {
final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet();
await metadataDb.removeCovers(removedRows.map((row) => row.filter).toSet());
_rows.removeAll(removedRows);
@ -85,8 +83,8 @@ class Covers with ChangeNotifier {
final visibleEntries = source.visibleEntries;
final jsonList = covers.all
.map((row) {
final id = row.contentId;
final path = visibleEntries.firstWhereOrNull((entry) => id == entry.contentId)?.path;
final entryId = row.entryId;
final path = visibleEntries.firstWhereOrNull((entry) => entryId == entry.id)?.path;
if (path == null) return null;
final volume = androidFileUtils.getStorageVolume(path)?.path;
@ -124,7 +122,7 @@ class Covers with ChangeNotifier {
final path = pContext.join(volume, relativePath);
final entry = visibleEntries.firstWhereOrNull((entry) => entry.path == path && filter.test(entry));
if (entry != null) {
covers.set(filter, entry.contentId);
covers.set(filter, entry.id);
} else {
debugPrint('failed to import cover for path=$path, filter=$filter');
}
@ -138,14 +136,14 @@ class Covers with ChangeNotifier {
@immutable
class CoverRow extends Equatable {
final CollectionFilter filter;
final int contentId;
final int entryId;
@override
List<Object?> get props => [filter, contentId];
List<Object?> get props => [filter, entryId];
const CoverRow({
required this.filter,
required this.contentId,
required this.entryId,
});
static CoverRow? fromMap(Map map) {
@ -153,12 +151,12 @@ class CoverRow extends Equatable {
if (filter == null) return null;
return CoverRow(
filter: filter,
contentId: map['contentId'],
entryId: map['entryId'],
);
}
Map<String, dynamic> toMap() => {
'filter': filter.toJson(),
'contentId': contentId,
'entryId': entryId,
};
}

View file

@ -0,0 +1,108 @@
import 'package:aves/model/covers.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/video_playback.dart';
abstract class MetadataDb {
int get nextId;
Future<void> init();
Future<int> dbFileSize();
Future<void> reset();
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes});
// entries
Future<void> clearEntries();
Future<Set<AvesEntry>> loadEntries({String? directory});
Future<Set<AvesEntry>> loadEntriesById(Iterable<int> ids);
Future<void> saveEntries(Iterable<AvesEntry> entries);
Future<void> updateEntry(int id, AvesEntry entry);
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
// date taken
Future<void> clearDates();
Future<Map<int?, int?>> loadDates();
// catalog metadata
Future<void> clearCatalogMetadata();
Future<Set<CatalogMetadata>> loadCatalogMetadata();
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Iterable<int> ids);
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries);
Future<void> updateCatalogMetadata(int id, CatalogMetadata? metadata);
// address
Future<void> clearAddresses();
Future<Set<AddressDetails>> loadAddresses();
Future<Set<AddressDetails>> loadAddressesById(Iterable<int> ids);
Future<void> saveAddresses(Set<AddressDetails> addresses);
Future<void> updateAddress(int id, AddressDetails? address);
// trash
Future<void> clearTrashDetails();
Future<Set<TrashDetails>> loadAllTrashDetails();
Future<void> updateTrash(int id, TrashDetails? details);
// favourites
Future<void> clearFavourites();
Future<Set<FavouriteRow>> loadAllFavourites();
Future<void> addFavourites(Iterable<FavouriteRow> rows);
Future<void> updateFavouriteId(int id, FavouriteRow row);
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
// covers
Future<void> clearCovers();
Future<Set<CoverRow>> loadAllCovers();
Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int id, CoverRow row);
Future<void> removeCovers(Set<CollectionFilter> filters);
// video playback
Future<void> clearVideoPlayback();
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
Future<VideoPlaybackRow?> loadVideoPlayback(int? id);
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
Future<void> removeVideoPlayback(Iterable<int> ids);
}

View file

@ -1,106 +1,22 @@
import 'dart:io';
import 'package:aves/model/covers.dart';
import 'package:aves/model/db/db_metadata.dart';
import 'package:aves/model/db/db_metadata_sqflite_upgrade.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata_db_upgrade.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/video_playback.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
abstract class MetadataDb {
Future<void> init();
Future<int> dbFileSize();
Future<void> reset();
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes});
// entries
Future<void> clearEntries();
Future<Set<AvesEntry>> loadAllEntries();
Future<void> saveEntries(Iterable<AvesEntry> entries);
Future<void> updateEntryId(int oldId, AvesEntry entry);
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
// date taken
Future<void> clearDates();
Future<Map<int?, int?>> loadDates();
// catalog metadata
Future<void> clearMetadataEntries();
Future<List<CatalogMetadata>> loadAllMetadataEntries();
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries);
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata);
// address
Future<void> clearAddresses();
Future<List<AddressDetails>> loadAllAddresses();
Future<void> saveAddresses(Set<AddressDetails> addresses);
Future<void> updateAddressId(int oldId, AddressDetails? address);
// favourites
Future<void> clearFavourites();
Future<Set<FavouriteRow>> loadAllFavourites();
Future<void> addFavourites(Iterable<FavouriteRow> rows);
Future<void> updateFavouriteId(int oldId, FavouriteRow row);
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
// covers
Future<void> clearCovers();
Future<Set<CoverRow>> loadAllCovers();
Future<void> addCovers(Iterable<CoverRow> rows);
Future<void> updateCoverEntryId(int oldId, CoverRow row);
Future<void> removeCovers(Set<CollectionFilter> filters);
// video playback
Future<void> clearVideoPlayback();
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback();
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId);
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
Future<void> updateVideoPlaybackId(int oldId, int? newId);
Future<void> removeVideoPlayback(Set<int> contentIds);
}
class SqfliteMetadataDb implements MetadataDb {
late Future<Database> _database;
late Database _db;
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
@ -110,15 +26,22 @@ class SqfliteMetadataDb implements MetadataDb {
static const addressTable = 'address';
static const favouriteTable = 'favourites';
static const coverTable = 'covers';
static const trashTable = 'trash';
static const videoPlaybackTable = 'videoPlayback';
static int _lastId = 0;
@override
int get nextId => ++_lastId;
@override
Future<void> init() async {
_database = openDatabase(
_db = await openDatabase(
await path,
onCreate: (db, version) async {
await db.execute('CREATE TABLE $entryTable('
'contentId INTEGER PRIMARY KEY'
'id INTEGER PRIMARY KEY'
', contentId INTEGER'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
@ -130,13 +53,14 @@ class SqfliteMetadataDb implements MetadataDb {
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0'
')');
await db.execute('CREATE TABLE $dateTakenTable('
'contentId INTEGER PRIMARY KEY'
'id INTEGER PRIMARY KEY'
', dateMillis INTEGER'
')');
await db.execute('CREATE TABLE $metadataTable('
'contentId INTEGER PRIMARY KEY'
'id INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', flags INTEGER'
@ -148,7 +72,7 @@ class SqfliteMetadataDb implements MetadataDb {
', rating INTEGER'
')');
await db.execute('CREATE TABLE $addressTable('
'contentId INTEGER PRIMARY KEY'
'id INTEGER PRIMARY KEY'
', addressLine TEXT'
', countryCode TEXT'
', countryName TEXT'
@ -156,21 +80,28 @@ class SqfliteMetadataDb implements MetadataDb {
', locality TEXT'
')');
await db.execute('CREATE TABLE $favouriteTable('
'contentId INTEGER PRIMARY KEY'
', path TEXT'
'id INTEGER PRIMARY KEY'
')');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
', entryId INTEGER'
')');
await db.execute('CREATE TABLE $trashTable('
'id INTEGER PRIMARY KEY'
', path TEXT'
', dateMillis INTEGER'
')');
await db.execute('CREATE TABLE $videoPlaybackTable('
'contentId INTEGER PRIMARY KEY'
'id INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
},
onUpgrade: MetadataDbUpgrader.upgradeDb,
version: 6,
version: 7,
);
final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable');
_lastId = (maxIdRows.firstOrNull?['maxId'] as int?) ?? 0;
}
@override
@ -182,22 +113,22 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<void> reset() async {
debugPrint('$runtimeType reset');
await (await _database).close();
await _db.close();
await deleteDatabase(await path);
await init();
}
@override
Future<void> removeIds(Set<int> contentIds, {Set<EntryDataType>? dataTypes}) async {
if (contentIds.isEmpty) return;
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes}) async {
if (ids.isEmpty) return;
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
const where = 'contentId = ?';
contentIds.forEach((id) {
// using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
const where = 'id = ?';
const coverWhere = 'entryId = ?';
ids.forEach((id) {
final whereArgs = [id];
if (_dataTypes.contains(EntryDataType.basic)) {
batch.delete(entryTable, where: where, whereArgs: whereArgs);
@ -211,7 +142,8 @@ class SqfliteMetadataDb implements MetadataDb {
}
if (_dataTypes.contains(EntryDataType.references)) {
batch.delete(favouriteTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: where, whereArgs: whereArgs);
batch.delete(coverTable, where: coverWhere, whereArgs: whereArgs);
batch.delete(trashTable, where: where, whereArgs: whereArgs);
batch.delete(videoPlaybackTable, where: where, whereArgs: whereArgs);
}
});
@ -222,35 +154,54 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<void> clearEntries() async {
final db = await _database;
final count = await db.delete(entryTable, where: '1');
final count = await _db.delete(entryTable, where: '1');
debugPrint('$runtimeType clearEntries deleted $count rows');
}
@override
Future<Set<AvesEntry>> loadAllEntries() async {
final db = await _database;
final maps = await db.query(entryTable);
final entries = maps.map(AvesEntry.fromMap).toSet();
return entries;
Future<Set<AvesEntry>> loadEntries({String? directory}) async {
if (directory != null) {
final separator = pContext.separator;
if (!directory.endsWith(separator)) {
directory = '$directory$separator';
}
const where = 'path LIKE ?';
final whereArgs = ['$directory%'];
final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs);
final dirLength = directory.length;
return rows
.whereNot((row) {
// skip entries in subfolders
final path = row['path'] as String?;
return path == null || path.substring(dirLength).contains(separator);
})
.map(AvesEntry.fromMap)
.toSet();
}
final rows = await _db.query(entryTable);
return rows.map(AvesEntry.fromMap).toSet();
}
@override
Future<Set<AvesEntry>> loadEntriesById(Iterable<int> ids) => _getByIds(ids, entryTable, AvesEntry.fromMap);
@override
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
if (entries.isEmpty) return;
final stopwatch = Stopwatch()..start();
final db = await _database;
final batch = db.batch();
final batch = _db.batch();
entries.forEach((entry) => _batchInsertEntry(batch, entry));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveEntries complete in ${stopwatch.elapsed.inMilliseconds}ms for ${entries.length} entries');
}
@override
Future<void> updateEntryId(int oldId, AvesEntry entry) async {
final db = await _database;
final batch = db.batch();
batch.delete(entryTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> updateEntry(int id, AvesEntry entry) async {
final batch = _db.batch();
batch.delete(entryTable, where: 'id = ?', whereArgs: [id]);
_batchInsertEntry(batch, entry);
await batch.commit(noResult: true);
}
@ -265,58 +216,53 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<Set<AvesEntry>> searchEntries(String query, {int? limit}) async {
final db = await _database;
final maps = await db.query(
final rows = await _db.query(
entryTable,
where: 'title LIKE ?',
whereArgs: ['%$query%'],
orderBy: 'sourceDateTakenMillis DESC',
limit: limit,
);
return maps.map(AvesEntry.fromMap).toSet();
return rows.map(AvesEntry.fromMap).toSet();
}
// date taken
@override
Future<void> clearDates() async {
final db = await _database;
final count = await db.delete(dateTakenTable, where: '1');
final count = await _db.delete(dateTakenTable, where: '1');
debugPrint('$runtimeType clearDates deleted $count rows');
}
@override
Future<Map<int?, int?>> loadDates() async {
final db = await _database;
final maps = await db.query(dateTakenTable);
final metadataEntries = Map.fromEntries(maps.map((map) => MapEntry(map['contentId'] as int, (map['dateMillis'] ?? 0) as int)));
return metadataEntries;
final rows = await _db.query(dateTakenTable);
return Map.fromEntries(rows.map((map) => MapEntry(map['id'] as int, (map['dateMillis'] ?? 0) as int)));
}
// catalog metadata
@override
Future<void> clearMetadataEntries() async {
final db = await _database;
final count = await db.delete(metadataTable, where: '1');
Future<void> clearCatalogMetadata() async {
final count = await _db.delete(metadataTable, where: '1');
debugPrint('$runtimeType clearMetadataEntries deleted $count rows');
}
@override
Future<List<CatalogMetadata>> loadAllMetadataEntries() async {
final db = await _database;
final maps = await db.query(metadataTable);
final metadataEntries = maps.map(CatalogMetadata.fromMap).toList();
return metadataEntries;
Future<Set<CatalogMetadata>> loadCatalogMetadata() async {
final rows = await _db.query(metadataTable);
return rows.map(CatalogMetadata.fromMap).toSet();
}
@override
Future<void> saveMetadata(Set<CatalogMetadata> metadataEntries) async {
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Iterable<int> ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap);
@override
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries) async {
if (metadataEntries.isEmpty) return;
final stopwatch = Stopwatch()..start();
try {
final db = await _database;
final batch = db.batch();
final batch = _db.batch();
metadataEntries.forEach((metadata) => _batchInsertMetadata(batch, metadata));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
@ -326,11 +272,10 @@ class SqfliteMetadataDb implements MetadataDb {
}
@override
Future<void> updateMetadataId(int oldId, CatalogMetadata? metadata) async {
final db = await _database;
final batch = db.batch();
batch.delete(dateTakenTable, where: 'contentId = ?', whereArgs: [oldId]);
batch.delete(metadataTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> updateCatalogMetadata(int id, CatalogMetadata? metadata) async {
final batch = _db.batch();
batch.delete(dateTakenTable, where: 'id = ?', whereArgs: [id]);
batch.delete(metadataTable, where: 'id = ?', whereArgs: [id]);
_batchInsertMetadata(batch, metadata);
await batch.commit(noResult: true);
}
@ -341,7 +286,7 @@ class SqfliteMetadataDb implements MetadataDb {
batch.insert(
dateTakenTable,
{
'contentId': metadata.contentId,
'id': metadata.id,
'dateMillis': metadata.dateMillis,
},
conflictAlgorithm: ConflictAlgorithm.replace,
@ -358,35 +303,33 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<void> clearAddresses() async {
final db = await _database;
final count = await db.delete(addressTable, where: '1');
final count = await _db.delete(addressTable, where: '1');
debugPrint('$runtimeType clearAddresses deleted $count rows');
}
@override
Future<List<AddressDetails>> loadAllAddresses() async {
final db = await _database;
final maps = await db.query(addressTable);
final addresses = maps.map(AddressDetails.fromMap).toList();
return addresses;
Future<Set<AddressDetails>> loadAddresses() async {
final rows = await _db.query(addressTable);
return rows.map(AddressDetails.fromMap).toSet();
}
@override
Future<Set<AddressDetails>> loadAddressesById(Iterable<int> ids) => _getByIds(ids, addressTable, AddressDetails.fromMap);
@override
Future<void> saveAddresses(Set<AddressDetails> addresses) async {
if (addresses.isEmpty) return;
final stopwatch = Stopwatch()..start();
final db = await _database;
final batch = db.batch();
final batch = _db.batch();
addresses.forEach((address) => _batchInsertAddress(batch, address));
await batch.commit(noResult: true);
debugPrint('$runtimeType saveAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${addresses.length} entries');
}
@override
Future<void> updateAddressId(int oldId, AddressDetails? address) async {
final db = await _database;
final batch = db.batch();
batch.delete(addressTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> updateAddress(int id, AddressDetails? address) async {
final batch = _db.batch();
batch.delete(addressTable, where: 'id = ?', whereArgs: [id]);
_batchInsertAddress(batch, address);
await batch.commit(noResult: true);
}
@ -400,37 +343,63 @@ class SqfliteMetadataDb implements MetadataDb {
);
}
// trash
@override
Future<void> clearTrashDetails() async {
final count = await _db.delete(trashTable, where: '1');
debugPrint('$runtimeType clearTrashDetails deleted $count rows');
}
@override
Future<Set<TrashDetails>> loadAllTrashDetails() async {
final rows = await _db.query(trashTable);
return rows.map(TrashDetails.fromMap).toSet();
}
@override
Future<void> updateTrash(int id, TrashDetails? details) async {
final batch = _db.batch();
batch.delete(trashTable, where: 'id = ?', whereArgs: [id]);
_batchInsertTrashDetails(batch, details);
await batch.commit(noResult: true);
}
void _batchInsertTrashDetails(Batch batch, TrashDetails? details) {
if (details == null) return;
batch.insert(
trashTable,
details.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// favourites
@override
Future<void> clearFavourites() async {
final db = await _database;
final count = await db.delete(favouriteTable, where: '1');
final count = await _db.delete(favouriteTable, where: '1');
debugPrint('$runtimeType clearFavourites deleted $count rows');
}
@override
Future<Set<FavouriteRow>> loadAllFavourites() async {
final db = await _database;
final maps = await db.query(favouriteTable);
final rows = maps.map(FavouriteRow.fromMap).toSet();
return rows;
final rows = await _db.query(favouriteTable);
return rows.map(FavouriteRow.fromMap).toSet();
}
@override
Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
if (rows.isEmpty) return;
final db = await _database;
final batch = db.batch();
final batch = _db.batch();
rows.forEach((row) => _batchInsertFavourite(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateFavouriteId(int oldId, FavouriteRow row) async {
final db = await _database;
final batch = db.batch();
batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> updateFavouriteId(int id, FavouriteRow row) async {
final batch = _db.batch();
batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]);
_batchInsertFavourite(batch, row);
await batch.commit(noResult: true);
}
@ -446,13 +415,12 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
if (rows.isEmpty) return;
final ids = rows.map((row) => row.contentId);
final ids = rows.map((row) => row.entryId);
if (ids.isEmpty) return;
final db = await _database;
// using array in `whereArgs` and using it with `where contentId IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
ids.forEach((id) => batch.delete(favouriteTable, where: 'contentId = ?', whereArgs: [id]));
// using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
final batch = _db.batch();
ids.forEach((id) => batch.delete(favouriteTable, where: 'id = ?', whereArgs: [id]));
await batch.commit(noResult: true);
}
@ -460,34 +428,29 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<void> clearCovers() async {
final db = await _database;
final count = await db.delete(coverTable, where: '1');
final count = await _db.delete(coverTable, where: '1');
debugPrint('$runtimeType clearCovers deleted $count rows');
}
@override
Future<Set<CoverRow>> loadAllCovers() async {
final db = await _database;
final maps = await db.query(coverTable);
final rows = maps.map(CoverRow.fromMap).whereNotNull().toSet();
return rows;
final rows = await _db.query(coverTable);
return rows.map(CoverRow.fromMap).whereNotNull().toSet();
}
@override
Future<void> addCovers(Iterable<CoverRow> rows) async {
if (rows.isEmpty) return;
final db = await _database;
final batch = db.batch();
final batch = _db.batch();
rows.forEach((row) => _batchInsertCover(batch, row));
await batch.commit(noResult: true);
}
@override
Future<void> updateCoverEntryId(int oldId, CoverRow row) async {
final db = await _database;
final batch = db.batch();
batch.delete(coverTable, where: 'contentId = ?', whereArgs: [oldId]);
Future<void> updateCoverEntryId(int id, CoverRow row) async {
final batch = _db.batch();
batch.delete(coverTable, where: 'entryId = ?', whereArgs: [id]);
_batchInsertCover(batch, row);
await batch.commit(noResult: true);
}
@ -504,9 +467,8 @@ class SqfliteMetadataDb implements MetadataDb {
Future<void> removeCovers(Set<CollectionFilter> filters) async {
if (filters.isEmpty) return;
final db = await _database;
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
final batch = _db.batch();
filters.forEach((filter) => batch.delete(coverTable, where: 'filter = ?', whereArgs: [filter.toJson()]));
await batch.commit(noResult: true);
}
@ -515,36 +477,31 @@ class SqfliteMetadataDb implements MetadataDb {
@override
Future<void> clearVideoPlayback() async {
final db = await _database;
final count = await db.delete(videoPlaybackTable, where: '1');
final count = await _db.delete(videoPlaybackTable, where: '1');
debugPrint('$runtimeType clearVideoPlayback deleted $count rows');
}
@override
Future<Set<VideoPlaybackRow>> loadAllVideoPlayback() async {
final db = await _database;
final maps = await db.query(videoPlaybackTable);
final rows = maps.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
return rows;
final rows = await _db.query(videoPlaybackTable);
return rows.map(VideoPlaybackRow.fromMap).whereNotNull().toSet();
}
@override
Future<VideoPlaybackRow?> loadVideoPlayback(int? contentId) async {
if (contentId == null) return null;
Future<VideoPlaybackRow?> loadVideoPlayback(int? id) async {
if (id == null) return null;
final db = await _database;
final maps = await db.query(videoPlaybackTable, where: 'contentId = ?', whereArgs: [contentId]);
if (maps.isEmpty) return null;
final rows = await _db.query(videoPlaybackTable, where: 'id = ?', whereArgs: [id]);
if (rows.isEmpty) return null;
return VideoPlaybackRow.fromMap(maps.first);
return VideoPlaybackRow.fromMap(rows.first);
}
@override
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows) async {
if (rows.isEmpty) return;
final db = await _database;
final batch = db.batch();
final batch = _db.batch();
rows.forEach((row) => _batchInsertVideoPlayback(batch, row));
await batch.commit(noResult: true);
}
@ -558,23 +515,23 @@ class SqfliteMetadataDb implements MetadataDb {
}
@override
Future<void> updateVideoPlaybackId(int oldId, int? newId) async {
if (newId != null) {
final db = await _database;
await db.update(videoPlaybackTable, {'contentId': newId}, where: 'contentId = ?', whereArgs: [oldId]);
} else {
await removeVideoPlayback({oldId});
}
}
Future<void> removeVideoPlayback(Iterable<int> ids) async {
if (ids.isEmpty) return;
@override
Future<void> removeVideoPlayback(Set<int> contentIds) async {
if (contentIds.isEmpty) return;
final db = await _database;
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
final batch = db.batch();
contentIds.forEach((id) => batch.delete(videoPlaybackTable, where: 'contentId = ?', whereArgs: [id]));
final batch = _db.batch();
ids.forEach((id) => batch.delete(videoPlaybackTable, where: 'id = ?', whereArgs: [id]));
await batch.commit(noResult: true);
}
// convenience methods
Future<Set<T>> _getByIds<T>(Iterable<int> ids, String table, T Function(Map<String, Object?> row) mapRow) async {
if (ids.isEmpty) return {};
final rows = await _db.query(
table,
where: 'id IN (${ids.join(',')})',
);
return rows.map(mapRow).toSet();
}
}

View file

@ -0,0 +1,272 @@
import 'package:aves/model/db/db_metadata_sqflite.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader {
static const entryTable = SqfliteMetadataDb.entryTable;
static const dateTakenTable = SqfliteMetadataDb.dateTakenTable;
static const metadataTable = SqfliteMetadataDb.metadataTable;
static const addressTable = SqfliteMetadataDb.addressTable;
static const favouriteTable = SqfliteMetadataDb.favouriteTable;
static const coverTable = SqfliteMetadataDb.coverTable;
static const trashTable = SqfliteMetadataDb.trashTable;
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
static Future<void> upgradeDb(Database db, int oldVersion, int newVersion) async {
while (oldVersion < newVersion) {
switch (oldVersion) {
case 1:
await _upgradeFrom1(db);
break;
case 2:
await _upgradeFrom2(db);
break;
case 3:
await _upgradeFrom3(db);
break;
case 4:
await _upgradeFrom4(db);
break;
case 5:
await _upgradeFrom5(db);
break;
case 6:
await _upgradeFrom6(db);
break;
}
oldVersion++;
}
}
static Future<void> _upgradeFrom1(Database db) async {
debugPrint('upgrading DB from v1');
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable('
'contentId INTEGER PRIMARY KEY'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
}
static Future<void> _upgradeFrom2(Database db) async {
debugPrint('upgrading DB from v2');
// merge columns 'isAnimated' and 'isFlipped' into 'flags'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', flags INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
}
static Future<void> _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}
static Future<void> _upgradeFrom4(Database db) async {
debugPrint('upgrading DB from v4');
await db.execute('CREATE TABLE $videoPlaybackTable('
'contentId INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
}
static Future<void> _upgradeFrom5(Database db) async {
debugPrint('upgrading DB from v5');
await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;');
}
static Future<void> _upgradeFrom6(Database db) async {
debugPrint('upgrading DB from v6');
// new primary key column `id` instead of `contentId`
// new column `trashed`
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable('
'id INTEGER PRIMARY KEY'
', contentId INTEGER'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
', trashed INTEGER DEFAULT 0'
')');
await db.rawInsert('INSERT INTO $newEntryTable(id,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newDateTakenTable = '${dateTakenTable}TEMP';
await db.execute('CREATE TABLE $newDateTakenTable('
'id INTEGER PRIMARY KEY'
', dateMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newDateTakenTable(id,dateMillis)'
' SELECT contentId,dateMillis'
' FROM $dateTakenTable;');
await db.execute('DROP TABLE $dateTakenTable;');
await db.execute('ALTER TABLE $newDateTakenTable RENAME TO $dateTakenTable;');
});
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'id INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', flags INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
', rating INTEGER'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(id,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating)'
' SELECT contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude,rating'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newAddressTable = '${addressTable}TEMP';
await db.execute('CREATE TABLE $newAddressTable('
'id INTEGER PRIMARY KEY'
', addressLine TEXT'
', countryCode TEXT'
', countryName TEXT'
', adminArea TEXT'
', locality TEXT'
')');
await db.rawInsert('INSERT INTO $newAddressTable(id,addressLine,countryCode,countryName,adminArea,locality)'
' SELECT contentId,addressLine,countryCode,countryName,adminArea,locality'
' FROM $addressTable;');
await db.execute('DROP TABLE $addressTable;');
await db.execute('ALTER TABLE $newAddressTable RENAME TO $addressTable;');
});
// rename column `contentId` to `id`
await db.transaction((txn) async {
const newVideoPlaybackTable = '${videoPlaybackTable}TEMP';
await db.execute('CREATE TABLE $newVideoPlaybackTable('
'id INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newVideoPlaybackTable(id,resumeTimeMillis)'
' SELECT contentId,resumeTimeMillis'
' FROM $videoPlaybackTable;');
await db.execute('DROP TABLE $videoPlaybackTable;');
await db.execute('ALTER TABLE $newVideoPlaybackTable RENAME TO $videoPlaybackTable;');
});
// rename column `contentId` to `id`
// remove column `path`
await db.transaction((txn) async {
const newFavouriteTable = '${favouriteTable}TEMP';
await db.execute('CREATE TABLE $newFavouriteTable('
'id INTEGER PRIMARY KEY'
')');
await db.rawInsert('INSERT INTO $newFavouriteTable(id)'
' SELECT contentId'
' FROM $favouriteTable;');
await db.execute('DROP TABLE $favouriteTable;');
await db.execute('ALTER TABLE $newFavouriteTable RENAME TO $favouriteTable;');
});
// rename column `contentId` to `entryId`
await db.transaction((txn) async {
const newCoverTable = '${coverTable}TEMP';
await db.execute('CREATE TABLE $newCoverTable('
'filter TEXT PRIMARY KEY'
', entryId INTEGER'
')');
await db.rawInsert('INSERT INTO $newCoverTable(filter,entryId)'
' SELECT filter,contentId'
' FROM $coverTable;');
await db.execute('DROP TABLE $coverTable;');
await db.execute('ALTER TABLE $newCoverTable RENAME TO $coverTable;');
});
// new table
await db.execute('CREATE TABLE $trashTable('
'id INTEGER PRIMARY KEY'
', path TEXT'
', dateMillis INTEGER'
')');
}
}

View file

@ -7,7 +7,9 @@ import 'package:aves/model/entry_cache.dart';
import 'package:aves/model/favourites.dart';
import 'package:aves/model/metadata/address.dart';
import 'package:aves/model/metadata/catalog.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/multipage.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/video/metadata.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/common/service_policy.dart';
@ -25,29 +27,27 @@ import 'package:latlong2/latlong.dart';
enum EntryDataType { basic, catalog, address, references }
class AvesEntry {
// `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode
int id;
String uri;
String? _path, _directory, _filename, _extension;
String? _path, _directory, _filename, _extension, _sourceTitle;
int? pageId, contentId;
final String sourceMimeType;
int width;
int height;
int sourceRotationDegrees;
int? sizeBytes;
String? _sourceTitle;
int width, height, sourceRotationDegrees;
int? sizeBytes, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
bool trashed;
// `dateModifiedSecs` can be missing in viewer mode
int? _dateModifiedSecs;
int? sourceDateTakenMillis;
int? _durationMillis;
int? _catalogDateMillis;
CatalogMetadata? _catalogMetadata;
AddressDetails? _addressDetails;
TrashDetails? trashDetails;
List<AvesEntry>? burstEntries;
final AChangeNotifier imageChangeNotifier = AChangeNotifier(), metadataChangeNotifier = AChangeNotifier(), addressChangeNotifier = AChangeNotifier();
AvesEntry({
required int? id,
required this.uri,
required String? path,
required this.contentId,
@ -61,8 +61,9 @@ class AvesEntry {
required int? dateModifiedSecs,
required this.sourceDateTakenMillis,
required int? durationMillis,
required this.trashed,
this.burstEntries,
}) {
}) : id = id ?? 0 {
this.path = path;
this.sourceTitle = sourceTitle;
this.dateModifiedSecs = dateModifiedSecs;
@ -74,6 +75,7 @@ class AvesEntry {
bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType);
AvesEntry copyWith({
int? id,
String? uri,
String? path,
int? contentId,
@ -81,11 +83,12 @@ class AvesEntry {
int? dateModifiedSecs,
List<AvesEntry>? burstEntries,
}) {
final copyContentId = contentId ?? this.contentId;
final copyEntryId = id ?? this.id;
final copied = AvesEntry(
id: copyEntryId,
uri: uri ?? this.uri,
path: path ?? this.path,
contentId: copyContentId,
contentId: contentId ?? this.contentId,
pageId: null,
sourceMimeType: sourceMimeType,
width: width,
@ -96,10 +99,12 @@ class AvesEntry {
dateModifiedSecs: dateModifiedSecs ?? this.dateModifiedSecs,
sourceDateTakenMillis: sourceDateTakenMillis,
durationMillis: durationMillis,
trashed: trashed,
burstEntries: burstEntries ?? this.burstEntries,
)
..catalogMetadata = _catalogMetadata?.copyWith(contentId: copyContentId)
..addressDetails = _addressDetails?.copyWith(contentId: copyContentId);
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
..addressDetails = _addressDetails?.copyWith(id: copyEntryId)
..trashDetails = trashDetails?.copyWith(id: copyEntryId);
return copied;
}
@ -107,6 +112,7 @@ class AvesEntry {
// from DB or platform source entry
factory AvesEntry.fromMap(Map map) {
return AvesEntry(
id: map['id'] as int?,
uri: map['uri'] as String,
path: map['path'] as String?,
pageId: null,
@ -120,12 +126,14 @@ class AvesEntry {
dateModifiedSecs: map['dateModifiedSecs'] as int?,
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
durationMillis: map['durationMillis'] as int?,
trashed: (map['trashed'] as int? ?? 0) != 0,
);
}
// for DB only
Map<String, dynamic> toMap() {
return {
'id': id,
'uri': uri,
'path': path,
'contentId': contentId,
@ -138,6 +146,7 @@ class AvesEntry {
'dateModifiedSecs': dateModifiedSecs,
'sourceDateTakenMillis': sourceDateTakenMillis,
'durationMillis': durationMillis,
'trashed': trashed ? 1 : 0,
};
}
@ -151,7 +160,7 @@ class AvesEntry {
// so that we can reliably use instances in a `Set`, which requires consistent hash codes over time
@override
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path, pageId=$pageId}';
String toString() => '$runtimeType#${shortHash(this)}{id=$id, uri=$uri, path=$path, pageId=$pageId}';
set path(String? path) {
_path = path;
@ -179,7 +188,10 @@ class AvesEntry {
return _extension;
}
bool get isMissingAtPath => path != null && !File(path!).existsSync();
bool get isMissingAtPath {
final effectivePath = trashed ? trashDetails?.path : path;
return effectivePath != null && !File(effectivePath).existsSync();
}
// the MIME type reported by the Media Store is unreliable
// so we use the one found during cataloguing if possible
@ -233,7 +245,7 @@ class AvesEntry {
bool get is360 => _catalogMetadata?.is360 ?? false;
bool get canEdit => path != null;
bool get canEdit => path != null && !trashed;
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
@ -408,6 +420,18 @@ class AvesEntry {
return _durationText!;
}
bool get isExpiredTrash {
final dateMillis = trashDetails?.dateMillis;
if (dateMillis == null) return false;
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).isBefore(DateTime.now());
}
int? get trashDaysLeft {
final dateMillis = trashDetails?.dateMillis;
if (dateMillis == null) return null;
return DateTime.fromMillisecondsSinceEpoch(dateMillis).add(TrashMixin.binKeepDuration).difference(DateTime.now()).inDays;
}
// returns whether this entry has GPS coordinates
// (0, 0) coordinates are considered invalid, as it is likely a default value
bool get hasGps => (_catalogMetadata?.latitude ?? 0) != 0 || (_catalogMetadata?.longitude ?? 0) != 0;
@ -476,7 +500,7 @@ class AvesEntry {
};
await applyNewFields(fields, persist: persist);
}
catalogMetadata = CatalogMetadata(contentId: contentId);
catalogMetadata = CatalogMetadata(id: id);
} else {
if (isVideo && (!isSized || durationMillis == 0)) {
// exotic video that is not sized during loading
@ -519,7 +543,7 @@ class AvesEntry {
void setCountry(CountryCode? countryCode) {
if (hasFineAddress || countryCode == null) return;
addressDetails = AddressDetails(
contentId: contentId,
id: id,
countryCode: countryCode.alpha2,
countryName: countryCode.alpha3,
);
@ -542,7 +566,7 @@ class AvesEntry {
final cn = address.countryName;
final aa = address.adminArea;
addressDetails = AddressDetails(
contentId: contentId,
id: id,
countryCode: cc,
countryName: cn,
adminArea: aa,
@ -619,7 +643,7 @@ class AvesEntry {
if (persist) {
await metadataDb.saveEntries({this});
if (catalogMetadata != null) await metadataDb.saveMetadata({catalogMetadata!});
if (catalogMetadata != null) await metadataDb.saveCatalogMetadata({catalogMetadata!});
}
await _onVisualFieldChanged(oldMimeType, oldDateModifiedSecs, oldRotationDegrees, oldIsFlipped);
@ -638,7 +662,7 @@ class AvesEntry {
_tags = null;
if (persist) {
await metadataDb.removeIds({contentId!}, dataTypes: dataTypes);
await metadataDb.removeIds({id}, dataTypes: dataTypes);
}
final updatedEntry = await mediaFileService.getEntry(uri, mimeType);
@ -689,7 +713,7 @@ class AvesEntry {
Future<void> removeFromFavourites() async {
if (isFavourite) {
await favourites.remove({this});
await favourites.removeEntries({this});
}
}
@ -720,7 +744,7 @@ class AvesEntry {
pages: burstEntries!
.mapIndexed((index, entry) => SinglePageInfo(
index: index,
pageId: entry.contentId!,
pageId: entry.id,
isDefault: index == 0,
uri: entry.uri,
mimeType: entry.mimeType,

View file

@ -22,7 +22,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
final appliedModifier = await _applyDateModifierToEntry(userModifier);
if (appliedModifier == null) {
await reportService.recordError('failed to get date for modifier=$userModifier, uri=$uri', null);
await reportService.recordError('failed to get date for modifier=$userModifier, entry=$this', null);
return {};
}
@ -259,7 +259,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
);
}
// convenience
// convenience methods
// This method checks whether the item already has a metadata date,
// and adds a date (the file modified date) via Exif if possible.

View file

@ -19,11 +19,11 @@ class Favourites with ChangeNotifier {
int get count => _rows.length;
Set<int> get all => Set.unmodifiable(_rows.map((v) => v.contentId));
Set<int> get all => Set.unmodifiable(_rows.map((v) => v.entryId));
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.contentId == entry.contentId);
bool isFavourite(AvesEntry entry) => _rows.any((row) => row.entryId == entry.id);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(contentId: entry.contentId!, path: entry.path!);
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(entryId: entry.id);
Future<void> add(Set<AvesEntry> entries) async {
final newRows = entries.map(_entryToRow);
@ -34,9 +34,10 @@ class Favourites with ChangeNotifier {
notifyListeners();
}
Future<void> remove(Set<AvesEntry> entries) async {
final contentIds = entries.map((entry) => entry.contentId).toSet();
final removedRows = _rows.where((row) => contentIds.contains(row.contentId)).toSet();
Future<void> removeEntries(Set<AvesEntry> entries) => removeIds(entries.map((entry) => entry.id).toSet());
Future<void> removeIds(Set<int> entryIds) async {
final removedRows = _rows.where((row) => entryIds.contains(row.entryId)).toSet();
await metadataDb.removeFavourites(removedRows);
removedRows.forEach(_rows.remove);
@ -44,19 +45,6 @@ class Favourites with ChangeNotifier {
notifyListeners();
}
Future<void> moveEntry(int oldContentId, AvesEntry entry) async {
final oldRow = _rows.firstWhereOrNull((row) => row.contentId == oldContentId);
if (oldRow == null) return;
final newRow = _entryToRow(entry);
await metadataDb.updateFavouriteId(oldContentId, newRow);
_rows.remove(oldRow);
_rows.add(newRow);
notifyListeners();
}
Future<void> clear() async {
await metadataDb.clearFavourites();
_rows.clear();
@ -69,7 +57,7 @@ class Favourites with ChangeNotifier {
Map<String, List<String>>? export(CollectionSource source) {
final visibleEntries = source.visibleEntries;
final ids = favourites.all;
final paths = visibleEntries.where((entry) => ids.contains(entry.contentId)).map((entry) => entry.path).whereNotNull().toSet();
final paths = visibleEntries.where((entry) => ids.contains(entry.id)).map((entry) => entry.path).whereNotNull().toSet();
final byVolume = groupBy<String, StorageVolume?>(paths, androidFileUtils.getStorageVolume);
final jsonMap = Map.fromEntries(byVolume.entries.map((kv) {
final volume = kv.key?.path;
@ -117,26 +105,22 @@ class Favourites with ChangeNotifier {
@immutable
class FavouriteRow extends Equatable {
final int contentId;
final String path;
final int entryId;
@override
List<Object?> get props => [contentId, path];
List<Object?> get props => [entryId];
const FavouriteRow({
required this.contentId,
required this.path,
required this.entryId,
});
factory FavouriteRow.fromMap(Map map) {
return FavouriteRow(
contentId: map['contentId'] ?? 0,
path: map['path'] ?? '',
entryId: map['id'] as int,
);
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'path': path,
'id': entryId,
};
}

View file

@ -1,6 +1,7 @@
import 'package:aves/image_providers/app_icon_image_provider.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/identity/aves_icons.dart';
@ -57,21 +58,36 @@ class AlbumFilter extends CollectionFilter {
Future<Color> color(BuildContext context) {
// do not use async/await and rely on `SynchronousFuture`
// to prevent rebuilding of the `FutureBuilder` listening on this future
if (androidFileUtils.getAlbumType(album) == AlbumType.app) {
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!);
final albumType = androidFileUtils.getAlbumType(album);
switch (albumType) {
case AlbumType.regular:
break;
case AlbumType.app:
if (_appColors.containsKey(album)) return SynchronousFuture(_appColors[album]!);
final packageName = androidFileUtils.getAlbumAppPackageName(album);
if (packageName != null) {
return PaletteGenerator.fromImageProvider(
AppIconImage(packageName: packageName, size: 24),
).then((palette) async {
// `dominantColor` is most representative but can have low contrast with a dark background
// `vibrantColor` is usually representative and has good contrast with a dark background
final color = palette.vibrantColor?.color ?? (await super.color(context));
_appColors[album] = color;
return color;
});
}
final packageName = androidFileUtils.getAlbumAppPackageName(album);
if (packageName != null) {
return PaletteGenerator.fromImageProvider(
AppIconImage(packageName: packageName, size: 24),
).then((palette) async {
// `dominantColor` is most representative but can have low contrast with a dark background
// `vibrantColor` is usually representative and has good contrast with a dark background
final color = palette.vibrantColor?.color ?? (await super.color(context));
_appColors[album] = color;
return color;
});
}
break;
case AlbumType.camera:
return SynchronousFuture(AColors.albumCamera);
case AlbumType.download:
return SynchronousFuture(AColors.albumDownload);
case AlbumType.screenRecordings:
return SynchronousFuture(AColors.albumScreenRecordings);
case AlbumType.screenshots:
return SynchronousFuture(AColors.albumScreenshots);
case AlbumType.videoCaptures:
return SynchronousFuture(AColors.albumVideoCaptures);
}
return super.color(context);
}

View file

@ -1,7 +1,7 @@
import 'package:aves/l10n/l10n.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/enums/coordinate_format.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/geo_utils.dart';

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
@ -32,7 +33,7 @@ class FavouriteFilter extends CollectionFilter {
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size);
@override
Future<Color> color(BuildContext context) => SynchronousFuture(Colors.red);
Future<Color> color(BuildContext context) => SynchronousFuture(AColors.favourite);
@override
String get category => type;

View file

@ -10,6 +10,7 @@ import 'package:aves/model/filters/path.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/filters/type.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:collection/collection.dart';
@ -20,6 +21,7 @@ import 'package:flutter/widgets.dart';
@immutable
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
static const List<String> categoryOrder = [
TrashFilter.type,
QueryFilter.type,
MimeFilter.type,
AlbumFilter.type,
@ -64,6 +66,8 @@ abstract class CollectionFilter extends Equatable implements Comparable<Collecti
return TagFilter.fromMap(jsonMap);
case TypeFilter.type:
return TypeFilter.fromMap(jsonMap);
case TrashFilter.type:
return TrashFilter.instance;
}
}
} catch (error, stack) {

View file

@ -1,8 +1,11 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class MimeFilter extends CollectionFilter {
@ -12,6 +15,7 @@ class MimeFilter extends CollectionFilter {
late final EntryFilter _test;
late final String _label;
late final IconData _icon;
late final Color _color;
static final image = MimeFilter(MimeTypes.anyImage);
static final video = MimeFilter(MimeTypes.anyVideo);
@ -21,6 +25,7 @@ class MimeFilter extends CollectionFilter {
MimeFilter(this.mime) {
IconData? icon;
Color? color;
var lowMime = mime.toLowerCase();
if (lowMime.endsWith('/*')) {
lowMime = lowMime.substring(0, lowMime.length - 2);
@ -28,14 +33,17 @@ class MimeFilter extends CollectionFilter {
_label = lowMime.toUpperCase();
if (mime == MimeTypes.anyImage) {
icon = AIcons.image;
color = AColors.image;
} else if (mime == MimeTypes.anyVideo) {
icon = AIcons.video;
color = AColors.video;
}
} else {
_test = (entry) => entry.mimeType == lowMime;
_label = MimeUtils.displayType(lowMime);
}
_icon = icon ?? AIcons.vector;
_color = color ?? stringToColor(_label);
}
MimeFilter.fromMap(Map<String, dynamic> json)
@ -70,6 +78,9 @@ class MimeFilter extends CollectionFilter {
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
@override
Future<Color> color(BuildContext context) => SynchronousFuture(_color);
@override
String get category => type;

View file

@ -22,7 +22,7 @@ class QueryFilter extends CollectionFilter {
var upQuery = query.toUpperCase();
if (upQuery.startsWith('ID:')) {
final id = int.tryParse(upQuery.substring(3));
_test = (entry) => entry.contentId == id;
_test = (entry) => entry.id == id;
return;
}

View file

@ -0,0 +1,38 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:flutter/material.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
class TrashFilter extends CollectionFilter {
static const type = 'trash';
static const instance = TrashFilter._private();
@override
List<Object?> get props => [];
const TrashFilter._private();
@override
Map<String, dynamic> toMap() => {
'type': type,
};
@override
EntryFilter get test => (entry) => entry.trashed;
@override
String get universalLabel => type;
@override
String getLabel(BuildContext context) => context.l10n.filterBinLabel;
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.bin, size: size);
@override
String get category => type;
@override
String get key => type;
}

View file

@ -1,6 +1,8 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class TypeFilter extends CollectionFilter {
@ -16,6 +18,7 @@ class TypeFilter extends CollectionFilter {
final String itemType;
late final EntryFilter _test;
late final IconData _icon;
late final Color _color;
static final animated = TypeFilter._private(_animated);
static final geotiff = TypeFilter._private(_geotiff);
@ -32,26 +35,32 @@ class TypeFilter extends CollectionFilter {
case _animated:
_test = (entry) => entry.isAnimated;
_icon = AIcons.animated;
_color = AColors.animated;
break;
case _geotiff:
_test = (entry) => entry.isGeotiff;
_icon = AIcons.geo;
_color = AColors.geotiff;
break;
case _motionPhoto:
_test = (entry) => entry.isMotionPhoto;
_icon = AIcons.motionPhoto;
_color = AColors.motionPhoto;
break;
case _panorama:
_test = (entry) => entry.isImage && entry.is360;
_icon = AIcons.threeSixty;
_color = AColors.panorama;
break;
case _raw:
_test = (entry) => entry.isRaw;
_icon = AIcons.raw;
_color = AColors.raw;
break;
case _sphericalVideo:
_test = (entry) => entry.isVideo && entry.is360;
_icon = AIcons.threeSixty;
_color = AColors.sphericalVideo;
break;
}
}
@ -96,6 +105,9 @@ class TypeFilter extends CollectionFilter {
@override
Widget iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size);
@override
Future<Color> color(BuildContext context) => SynchronousFuture(_color);
@override
String get category => type;

View file

@ -4,16 +4,16 @@ import 'package:flutter/widgets.dart';
@immutable
class AddressDetails extends Equatable {
final int? contentId;
final int id;
final String? countryCode, countryName, adminArea, locality;
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
@override
List<Object?> get props => [contentId, countryCode, countryName, adminArea, locality];
List<Object?> get props => [id, countryCode, countryName, adminArea, locality];
const AddressDetails({
this.contentId,
required this.id,
this.countryCode,
this.countryName,
this.adminArea,
@ -21,10 +21,10 @@ class AddressDetails extends Equatable {
});
AddressDetails copyWith({
int? contentId,
int? id,
}) {
return AddressDetails(
contentId: contentId ?? this.contentId,
id: id ?? this.id,
countryCode: countryCode,
countryName: countryName,
adminArea: adminArea,
@ -34,7 +34,7 @@ class AddressDetails extends Equatable {
factory AddressDetails.fromMap(Map map) {
return AddressDetails(
contentId: map['contentId'] as int?,
id: map['id'] as int,
countryCode: map['countryCode'] as String?,
countryName: map['countryName'] as String?,
adminArea: map['adminArea'] as String?,
@ -43,7 +43,7 @@ class AddressDetails extends Equatable {
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'id': id,
'countryCode': countryCode,
'countryName': countryName,
'adminArea': adminArea,

View file

@ -2,7 +2,8 @@ import 'package:aves/services/geocoding_service.dart';
import 'package:flutter/foundation.dart';
class CatalogMetadata {
final int? contentId, dateMillis;
final int id;
final int? dateMillis;
final bool isAnimated, isGeotiff, is360, isMultiPage;
bool isFlipped;
int? rotationDegrees;
@ -19,7 +20,7 @@ class CatalogMetadata {
static const _isMultiPageMask = 1 << 4;
CatalogMetadata({
this.contentId,
required this.id,
this.mimeType,
this.dateMillis,
this.isAnimated = false,
@ -49,14 +50,14 @@ class CatalogMetadata {
}
CatalogMetadata copyWith({
int? contentId,
int? id,
String? mimeType,
int? dateMillis,
bool? isMultiPage,
int? rotationDegrees,
}) {
return CatalogMetadata(
contentId: contentId ?? this.contentId,
id: id ?? this.id,
mimeType: mimeType ?? this.mimeType,
dateMillis: dateMillis ?? this.dateMillis,
isAnimated: isAnimated,
@ -76,7 +77,7 @@ class CatalogMetadata {
factory CatalogMetadata.fromMap(Map map) {
final flags = map['flags'] ?? 0;
return CatalogMetadata(
contentId: map['contentId'],
id: map['id'],
mimeType: map['mimeType'],
dateMillis: map['dateMillis'] ?? 0,
isAnimated: flags & _isAnimatedMask != 0,
@ -95,7 +96,7 @@ class CatalogMetadata {
}
Map<String, dynamic> toMap() => {
'contentId': contentId,
'id': id,
'mimeType': mimeType,
'dateMillis': dateMillis,
'flags': (isAnimated ? _isAnimatedMask : 0) | (isFlipped ? _isFlippedMask : 0) | (isGeotiff ? _isGeotiffMask : 0) | (is360 ? _is360Mask : 0) | (isMultiPage ? _isMultiPageMask : 0),
@ -108,5 +109,5 @@ class CatalogMetadata {
};
@override
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}';
String toString() => '$runtimeType#${shortHash(this)}{id=$id, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, isMultiPage=$isMultiPage, rotationDegrees=$rotationDegrees, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription, latitude=$latitude, longitude=$longitude, rating=$rating}';
}

View file

@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
@immutable
class TrashDetails extends Equatable {
final int id;
final String path;
final int dateMillis;
@override
List<Object?> get props => [id, path, dateMillis];
const TrashDetails({
required this.id,
required this.path,
required this.dateMillis,
});
TrashDetails copyWith({
int? id,
}) {
return TrashDetails(
id: id ?? this.id,
path: path,
dateMillis: dateMillis,
);
}
factory TrashDetails.fromMap(Map map) {
return TrashDetails(
id: map['id'] as int,
path: map['path'] as String,
dateMillis: map['dateMillis'] as int,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'path': path,
'dateMillis': dateMillis,
};
}

View file

@ -1,132 +0,0 @@
import 'package:aves/model/metadata_db.dart';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
class MetadataDbUpgrader {
static const entryTable = SqfliteMetadataDb.entryTable;
static const metadataTable = SqfliteMetadataDb.metadataTable;
static const coverTable = SqfliteMetadataDb.coverTable;
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
// warning: "ALTER TABLE ... RENAME COLUMN ..." is not supported
// on SQLite <3.25.0, bundled on older Android devices
static Future<void> upgradeDb(Database db, int oldVersion, int newVersion) async {
while (oldVersion < newVersion) {
switch (oldVersion) {
case 1:
await _upgradeFrom1(db);
break;
case 2:
await _upgradeFrom2(db);
break;
case 3:
await _upgradeFrom3(db);
break;
case 4:
await _upgradeFrom4(db);
break;
case 5:
await _upgradeFrom5(db);
break;
}
oldVersion++;
}
}
static Future<void> _upgradeFrom1(Database db) async {
debugPrint('upgrading DB from v1');
// rename column 'orientationDegrees' to 'sourceRotationDegrees'
await db.transaction((txn) async {
const newEntryTable = '${entryTable}TEMP';
await db.execute('CREATE TABLE $newEntryTable('
'contentId INTEGER PRIMARY KEY'
', uri TEXT'
', path TEXT'
', sourceMimeType TEXT'
', width INTEGER'
', height INTEGER'
', sourceRotationDegrees INTEGER'
', sizeBytes INTEGER'
', title TEXT'
', dateModifiedSecs INTEGER'
', sourceDateTakenMillis INTEGER'
', durationMillis INTEGER'
')');
await db.rawInsert('INSERT INTO $newEntryTable(contentId,uri,path,sourceMimeType,width,height,sourceRotationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis)'
' SELECT contentId,uri,path,sourceMimeType,width,height,orientationDegrees,sizeBytes,title,dateModifiedSecs,sourceDateTakenMillis,durationMillis'
' FROM $entryTable;');
await db.execute('DROP TABLE $entryTable;');
await db.execute('ALTER TABLE $newEntryTable RENAME TO $entryTable;');
});
// rename column 'videoRotation' to 'rotationDegrees'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', isAnimated INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,isAnimated,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,isAnimated,videoRotation,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.rawInsert('UPDATE $newMetadataTable SET rotationDegrees = NULL WHERE rotationDegrees = 0;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
// new column 'isFlipped'
await db.execute('ALTER TABLE $metadataTable ADD COLUMN isFlipped INTEGER;');
}
static Future<void> _upgradeFrom2(Database db) async {
debugPrint('upgrading DB from v2');
// merge columns 'isAnimated' and 'isFlipped' into 'flags'
await db.transaction((txn) async {
const newMetadataTable = '${metadataTable}TEMP';
await db.execute('CREATE TABLE $newMetadataTable('
'contentId INTEGER PRIMARY KEY'
', mimeType TEXT'
', dateMillis INTEGER'
', flags INTEGER'
', rotationDegrees INTEGER'
', xmpSubjects TEXT'
', xmpTitleDescription TEXT'
', latitude REAL'
', longitude REAL'
')');
await db.rawInsert('INSERT INTO $newMetadataTable(contentId,mimeType,dateMillis,flags,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude)'
' SELECT contentId,mimeType,dateMillis,ifnull(isAnimated,0)+ifnull(isFlipped,0)*2,rotationDegrees,xmpSubjects,xmpTitleDescription,latitude,longitude'
' FROM $metadataTable;');
await db.execute('DROP TABLE $metadataTable;');
await db.execute('ALTER TABLE $newMetadataTable RENAME TO $metadataTable;');
});
}
static Future<void> _upgradeFrom3(Database db) async {
debugPrint('upgrading DB from v3');
await db.execute('CREATE TABLE $coverTable('
'filter TEXT PRIMARY KEY'
', contentId INTEGER'
')');
}
static Future<void> _upgradeFrom4(Database db) async {
debugPrint('upgrading DB from v4');
await db.execute('CREATE TABLE $videoPlaybackTable('
'contentId INTEGER PRIMARY KEY'
', resumeTimeMillis INTEGER'
')');
}
static Future<void> _upgradeFrom5(Database db) async {
debugPrint('upgrading DB from v5');
await db.execute('ALTER TABLE $metadataTable ADD COLUMN rating INTEGER;');
}
}

View file

@ -87,7 +87,11 @@ class MultiPageInfo {
// and retrieve cached images for it
final pageId = eraseDefaultPageId && pageInfo.isDefault ? null : pageInfo.pageId;
// dynamically extracted video is not in the trash like the original motion photo
final trashed = (mainEntry.isMotionPhoto && pageInfo.isVideo) ? false : mainEntry.trashed;
return AvesEntry(
id: mainEntry.id,
uri: pageInfo.uri ?? mainEntry.uri,
path: mainEntry.path,
contentId: mainEntry.contentId,
@ -101,13 +105,15 @@ class MultiPageInfo {
dateModifiedSecs: mainEntry.dateModifiedSecs,
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
trashed: trashed,
)
..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
mimeType: pageInfo.mimeType,
isMultiPage: false,
rotationDegrees: pageInfo.rotationDegrees,
)
..addressDetails = mainEntry.addressDetails?.copyWith();
..addressDetails = mainEntry.addressDetails?.copyWith()
..trashDetails = trashed ? mainEntry.trashDetails : null;
}
@override

View file

@ -3,7 +3,7 @@ import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
@ -16,12 +16,13 @@ class SettingsDefaults {
static const canUseAnalysisService = true;
static const isInstalledAppAccessAllowed = false;
static const isErrorReportingAllowed = false;
static const tileLayout = TileLayout.grid;
// navigation
static const mustBackTwiceToExit = true;
static const keepScreenOn = KeepScreenOn.viewerOnly;
static const homePage = HomePageSetting.collection;
static const tileLayout = TileLayout.grid;
// drawer
static const confirmationDialogs = ConfirmationDialog.values;
static final drawerTypeBookmarks = [
null,
MimeFilter.video,
@ -100,10 +101,17 @@ class SettingsDefaults {
// search
static const saveSearchHistory = true;
// bin
static const enableBin = true;
// accessibility
static const accessibilityAnimations = AccessibilityAnimations.system;
static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value
// file picker
static const filePickerShowHiddenFiles = false;
// platform settings
static const isRotationLocked = false;
static const areAnimationsRemoved = false;
}

View file

@ -0,0 +1,15 @@
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/widgets.dart';
import 'enums.dart';
extension ExtraConfirmationDialog on ConfirmationDialog {
String getName(BuildContext context) {
switch (this) {
case ConfirmationDialog.delete:
return context.l10n.settingsConfirmationDialogDeleteItems;
case ConfirmationDialog.moveToBin:
return context.l10n.settingsConfirmationDialogMoveToBinItems;
}
}
}

View file

@ -1,16 +1,18 @@
enum CoordinateFormat { dms, decimal }
enum AccessibilityAnimations { system, disabled, enabled }
enum AccessibilityTimeout { system, appDefault, s10, s30, s60, s120 }
enum EntryBackground { black, white, checkered }
enum ConfirmationDialog { delete, moveToBin }
enum HomePageSetting { collection, albums }
enum CoordinateFormat { dms, decimal }
enum EntryBackground { black, white, checkered }
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }
enum HomePageSetting { collection, albums }
enum KeepScreenOn { never, viewerOnly, always }
enum UnitSystem { metric, imperial }

View file

@ -7,25 +7,22 @@ import 'package:aves/model/actions/entry_set_actions.dart';
import 'package:aves/model/actions/video_actions.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/defaults.dart';
import 'package:aves/model/settings/enums.dart';
import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/enums/enums.dart';
import 'package:aves/model/settings/enums/map_style.dart';
import 'package:aves/model/source/enums.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
final Settings settings = Settings._private();
class Settings extends ChangeNotifier {
final EventChannel _platformSettingsChangeChannel = const EventChannel('deckers.thibault/aves/settings_change');
final StreamController<String> _updateStreamController = StreamController<String>.broadcast();
final StreamController<SettingsChangedEvent> _updateStreamController = StreamController<SettingsChangedEvent>.broadcast();
Stream<String> get updateStream => _updateStreamController.stream;
static SharedPreferences? _prefs;
Stream<SettingsChangedEvent> get updateStream => _updateStreamController.stream;
Settings._private();
@ -34,6 +31,9 @@ class Settings extends ChangeNotifier {
catalogTimeZoneKey,
videoShowRawTimedTextKey,
searchHistoryKey,
platformAccelerometerRotationKey,
platformTransitionAnimationScaleKey,
topEntryIdsKey,
};
// app
@ -42,14 +42,16 @@ class Settings extends ChangeNotifier {
static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed';
static const isErrorReportingAllowedKey = 'is_crashlytics_enabled';
static const localeKey = 'locale';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on';
static const homePageKey = 'home_page';
static const catalogTimeZoneKey = 'catalog_time_zone';
static const tileExtentPrefixKey = 'tile_extent_';
static const tileLayoutPrefixKey = 'tile_layout_';
static const topEntryIdsKey = 'top_entry_ids';
// drawer
// navigation
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const keepScreenOnKey = 'keep_screen_on';
static const homePageKey = 'home_page';
static const confirmationDialogsKey = 'confirmation_dialogs';
static const drawerTypeBookmarksKey = 'drawer_type_bookmarks';
static const drawerAlbumBookmarksKey = 'drawer_album_bookmarks';
static const drawerPageBookmarksKey = 'drawer_page_bookmarks';
@ -110,6 +112,9 @@ class Settings extends ChangeNotifier {
static const saveSearchHistoryKey = 'save_search_history';
static const searchHistoryKey = 'search_history';
// bin
static const enableBinKey = 'enable_bin';
// accessibility
static const accessibilityAnimationsKey = 'accessibility_animations';
static const timeToTakeActionKey = 'time_to_take_action';
@ -124,16 +129,10 @@ class Settings extends ChangeNotifier {
// cf Android `Settings.Global.TRANSITION_ANIMATION_SCALE`
static const platformTransitionAnimationScaleKey = 'transition_animation_scale';
bool get initialized => _prefs != null;
bool get initialized => settingsStore.initialized;
Future<void> init({
required bool monitorPlatformSettings,
bool isRotationLocked = false,
bool areAnimationsRemoved = false,
}) async {
_prefs = await SharedPreferences.getInstance();
_isRotationLocked = isRotationLocked;
_areAnimationsRemoved = areAnimationsRemoved;
Future<void> init({required bool monitorPlatformSettings}) async {
await settingsStore.init();
if (monitorPlatformSettings) {
_platformSettingsChangeChannel.receiveBroadcastStream().listen((event) => _onPlatformSettingsChange(event as Map?));
}
@ -141,9 +140,9 @@ class Settings extends ChangeNotifier {
Future<void> reset({required bool includeInternalKeys}) async {
if (includeInternalKeys) {
await _prefs!.clear();
await settingsStore.clear();
} else {
await Future.forEach<String>(_prefs!.getKeys().whereNot(internalKeys.contains), _prefs!.remove);
await Future.forEach<String>(settingsStore.getKeys().whereNot(Settings.internalKeys.contains), settingsStore.remove);
}
}
@ -189,7 +188,7 @@ class Settings extends ChangeNotifier {
Locale? get locale {
// exceptionally allow getting locale before settings are initialized
final tag = _prefs?.getString(localeKey);
final tag = initialized ? getString(localeKey) : null;
if (tag != null) {
final codes = tag.split(localeSeparator);
return Locale.fromSubtags(
@ -238,6 +237,24 @@ class Settings extends ChangeNotifier {
return _appliedLocale!;
}
String get catalogTimeZone => getString(catalogTimeZoneKey) ?? '';
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
double getTileExtent(String routeName) => getDouble(tileExtentPrefixKey + routeName) ?? 0;
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
TileLayout getTileLayout(String routeName) => getEnumOrDefault(tileLayoutPrefixKey + routeName, SettingsDefaults.tileLayout, TileLayout.values);
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString());
List<int>? get topEntryIds => getStringList(topEntryIdsKey)?.map(int.tryParse).whereNotNull().toList();
set topEntryIds(List<int>? newValue) => setAndNotify(topEntryIdsKey, newValue?.map((id) => id.toString()).whereNotNull().toList());
// navigation
bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, SettingsDefaults.mustBackTwiceToExit);
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
@ -250,22 +267,12 @@ class Settings extends ChangeNotifier {
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
String get catalogTimeZone => _prefs!.getString(catalogTimeZoneKey) ?? '';
List<ConfirmationDialog> get confirmationDialogs => getEnumListOrDefault(confirmationDialogsKey, SettingsDefaults.confirmationDialogs, ConfirmationDialog.values);
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
double getTileExtent(String routeName) => _prefs!.getDouble(tileExtentPrefixKey + routeName) ?? 0;
void setTileExtent(String routeName, double newValue) => setAndNotify(tileExtentPrefixKey + routeName, newValue);
TileLayout getTileLayout(String routeName) => getEnumOrDefault(tileLayoutPrefixKey + routeName, SettingsDefaults.tileLayout, TileLayout.values);
void setTileLayout(String routeName, TileLayout newValue) => setAndNotify(tileLayoutPrefixKey + routeName, newValue.toString());
// drawer
set confirmationDialogs(List<ConfirmationDialog> newValue) => setAndNotify(confirmationDialogsKey, newValue.map((v) => v.toString()).toList());
List<CollectionFilter?> get drawerTypeBookmarks =>
(_prefs!.getStringList(drawerTypeBookmarksKey))?.map((v) {
(getStringList(drawerTypeBookmarksKey))?.map((v) {
if (v.isEmpty) return null;
return CollectionFilter.fromJson(v);
}).toList() ??
@ -273,11 +280,11 @@ class Settings extends ChangeNotifier {
set drawerTypeBookmarks(List<CollectionFilter?> newValue) => setAndNotify(drawerTypeBookmarksKey, newValue.map((filter) => filter?.toJson() ?? '').toList());
List<String>? get drawerAlbumBookmarks => _prefs!.getStringList(drawerAlbumBookmarksKey);
List<String>? get drawerAlbumBookmarks => getStringList(drawerAlbumBookmarksKey);
set drawerAlbumBookmarks(List<String>? newValue) => setAndNotify(drawerAlbumBookmarksKey, newValue);
List<String> get drawerPageBookmarks => _prefs!.getStringList(drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks;
List<String> get drawerPageBookmarks => getStringList(drawerPageBookmarksKey) ?? SettingsDefaults.drawerPageBookmarks;
set drawerPageBookmarks(List<String> newValue) => setAndNotify(drawerPageBookmarksKey, newValue);
@ -341,14 +348,25 @@ class Settings extends ChangeNotifier {
set tagSortFactor(ChipSortFactor newValue) => setAndNotify(tagSortFactorKey, newValue.toString());
Set<CollectionFilter> get pinnedFilters => (_prefs!.getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
Set<CollectionFilter> get pinnedFilters => (getStringList(pinnedFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
set pinnedFilters(Set<CollectionFilter> newValue) => setAndNotify(pinnedFiltersKey, newValue.map((filter) => filter.toJson()).toList());
Set<CollectionFilter> get hiddenFilters => (_prefs!.getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
Set<CollectionFilter> get hiddenFilters => (getStringList(hiddenFiltersKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
set hiddenFilters(Set<CollectionFilter> newValue) => setAndNotify(hiddenFiltersKey, newValue.map((filter) => filter.toJson()).toList());
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final _hiddenFilters = hiddenFilters;
if (visible) {
_hiddenFilters.removeAll(filters);
} else {
_hiddenFilters.addAll(filters);
searchHistory = searchHistory..removeWhere(filters.contains);
}
hiddenFilters = _hiddenFilters;
}
// viewer
List<EntryAction> get viewerQuickActions => getEnumListOrDefault(viewerQuickActionsKey, SettingsDefaults.viewerQuickActions, EntryAction.values);
@ -415,7 +433,7 @@ class Settings extends ChangeNotifier {
// subtitles
double get subtitleFontSize => _prefs!.getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize;
double get subtitleFontSize => getDouble(subtitleFontSizeKey) ?? SettingsDefaults.subtitleFontSize;
set subtitleFontSize(double newValue) => setAndNotify(subtitleFontSizeKey, newValue);
@ -427,11 +445,11 @@ class Settings extends ChangeNotifier {
set subtitleShowOutline(bool newValue) => setAndNotify(subtitleShowOutlineKey, newValue);
Color get subtitleTextColor => Color(_prefs!.getInt(subtitleTextColorKey) ?? SettingsDefaults.subtitleTextColor.value);
Color get subtitleTextColor => Color(getInt(subtitleTextColorKey) ?? SettingsDefaults.subtitleTextColor.value);
set subtitleTextColor(Color newValue) => setAndNotify(subtitleTextColorKey, newValue.value);
Color get subtitleBackgroundColor => Color(_prefs!.getInt(subtitleBackgroundColorKey) ?? SettingsDefaults.subtitleBackgroundColor.value);
Color get subtitleBackgroundColor => Color(getInt(subtitleBackgroundColorKey) ?? SettingsDefaults.subtitleBackgroundColor.value);
set subtitleBackgroundColor(Color newValue) => setAndNotify(subtitleBackgroundColorKey, newValue.value);
@ -441,7 +459,7 @@ class Settings extends ChangeNotifier {
set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString());
double get infoMapZoom => _prefs!.getDouble(infoMapZoomKey) ?? SettingsDefaults.infoMapZoom;
double get infoMapZoom => getDouble(infoMapZoomKey) ?? SettingsDefaults.infoMapZoom;
set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
@ -459,10 +477,16 @@ class Settings extends ChangeNotifier {
set saveSearchHistory(bool newValue) => setAndNotify(saveSearchHistoryKey, newValue);
List<CollectionFilter> get searchHistory => (_prefs!.getStringList(searchHistoryKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toList();
List<CollectionFilter> get searchHistory => (getStringList(searchHistoryKey) ?? []).map(CollectionFilter.fromJson).whereNotNull().toList();
set searchHistory(List<CollectionFilter> newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList());
// bin
bool get enableBin => getBoolOrDefault(enableBinKey, SettingsDefaults.enableBin);
set enableBin(bool newValue) => setAndNotify(enableBinKey, newValue);
// accessibility
AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values);
@ -481,11 +505,19 @@ class Settings extends ChangeNotifier {
// convenience methods
int? getInt(String key) => settingsStore.getInt(key);
double? getDouble(String key) => settingsStore.getDouble(key);
String? getString(String key) => settingsStore.getString(key);
List<String>? getStringList(String key) => settingsStore.getStringList(key);
// ignore: avoid_positional_boolean_parameters
bool getBoolOrDefault(String key, bool defaultValue) => _prefs!.getBool(key) ?? defaultValue;
bool getBoolOrDefault(String key, bool defaultValue) => settingsStore.getBool(key) ?? defaultValue;
T getEnumOrDefault<T>(String key, T defaultValue, Iterable<T> values) {
final valueString = _prefs!.getString(key);
final valueString = settingsStore.getString(key);
for (final v in values) {
if (v.toString() == valueString) {
return v;
@ -495,31 +527,31 @@ class Settings extends ChangeNotifier {
}
List<T> getEnumListOrDefault<T extends Object>(String key, List<T> defaultValue, Iterable<T> values) {
return _prefs!.getStringList(key)?.map((s) => values.firstWhereOrNull((v) => v.toString() == s)).whereNotNull().toList() ?? defaultValue;
return settingsStore.getStringList(key)?.map((s) => values.firstWhereOrNull((v) => v.toString() == s)).whereNotNull().toList() ?? defaultValue;
}
void setAndNotify(String key, dynamic newValue) {
var oldValue = _prefs!.get(key);
var oldValue = settingsStore.get(key);
if (newValue == null) {
_prefs!.remove(key);
settingsStore.remove(key);
} else if (newValue is String) {
oldValue = _prefs!.getString(key);
_prefs!.setString(key, newValue);
oldValue = settingsStore.getString(key);
settingsStore.setString(key, newValue);
} else if (newValue is List<String>) {
oldValue = _prefs!.getStringList(key);
_prefs!.setStringList(key, newValue);
oldValue = settingsStore.getStringList(key);
settingsStore.setStringList(key, newValue);
} else if (newValue is int) {
oldValue = _prefs!.getInt(key);
_prefs!.setInt(key, newValue);
oldValue = settingsStore.getInt(key);
settingsStore.setInt(key, newValue);
} else if (newValue is double) {
oldValue = _prefs!.getDouble(key);
_prefs!.setDouble(key, newValue);
oldValue = settingsStore.getDouble(key);
settingsStore.setDouble(key, newValue);
} else if (newValue is bool) {
oldValue = _prefs!.getBool(key);
_prefs!.setBool(key, newValue);
oldValue = settingsStore.getBool(key);
settingsStore.setBool(key, newValue);
}
if (oldValue != newValue) {
_updateStreamController.add(key);
_updateStreamController.add(SettingsChangedEvent(key, oldValue, newValue));
notifyListeners();
}
}
@ -527,50 +559,33 @@ class Settings extends ChangeNotifier {
// platform settings
void _onPlatformSettingsChange(Map? fields) {
var changed = false;
fields?.forEach((key, value) {
switch (key) {
case platformAccelerometerRotationKey:
if (value is num) {
final newValue = value == 0;
if (_isRotationLocked != newValue) {
_isRotationLocked = newValue;
if (!_isRotationLocked) {
windowService.requestOrientation();
}
_updateStreamController.add(key);
changed = true;
}
isRotationLocked = value == 0;
}
break;
case platformTransitionAnimationScaleKey:
if (value is num) {
final newValue = value == 0;
if (_areAnimationsRemoved != newValue) {
_areAnimationsRemoved = newValue;
_updateStreamController.add(key);
changed = true;
}
areAnimationsRemoved = value == 0;
}
}
});
if (changed) {
notifyListeners();
}
}
bool _isRotationLocked = false;
bool get isRotationLocked => getBoolOrDefault(platformAccelerometerRotationKey, SettingsDefaults.isRotationLocked);
bool get isRotationLocked => _isRotationLocked;
set isRotationLocked(bool newValue) => setAndNotify(platformAccelerometerRotationKey, newValue);
bool _areAnimationsRemoved = false;
bool get areAnimationsRemoved => getBoolOrDefault(platformTransitionAnimationScaleKey, SettingsDefaults.areAnimationsRemoved);
bool get areAnimationsRemoved => _areAnimationsRemoved;
set areAnimationsRemoved(bool newValue) => setAndNotify(platformTransitionAnimationScaleKey, newValue);
// import/export
Map<String, dynamic> export() => Map.fromEntries(
_prefs!.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, _prefs!.get(k))),
settingsStore.getKeys().whereNot(internalKeys.contains).map((k) => MapEntry(k, settingsStore.get(k))),
);
Future<void> import(dynamic jsonMap) async {
@ -579,37 +594,39 @@ class Settings extends ChangeNotifier {
await reset(includeInternalKeys: false);
// apply user modifications
jsonMap.forEach((key, value) {
if (value == null) {
_prefs!.remove(key);
jsonMap.forEach((key, newValue) {
final oldValue = settingsStore.get(key);
if (newValue == null) {
settingsStore.remove(key);
} else if (key.startsWith(tileExtentPrefixKey)) {
if (value is double) {
_prefs!.setDouble(key, value);
if (newValue is double) {
settingsStore.setDouble(key, newValue);
} else {
debugPrint('failed to import key=$key, value=$value is not a double');
debugPrint('failed to import key=$key, value=$newValue is not a double');
}
} else if (key.startsWith(tileLayoutPrefixKey)) {
if (value is String) {
_prefs!.setString(key, value);
if (newValue is String) {
settingsStore.setString(key, newValue);
} else {
debugPrint('failed to import key=$key, value=$value is not a string');
debugPrint('failed to import key=$key, value=$newValue is not a string');
}
} else {
switch (key) {
case subtitleTextColorKey:
case subtitleBackgroundColorKey:
if (value is int) {
_prefs!.setInt(key, value);
if (newValue is int) {
settingsStore.setInt(key, newValue);
} else {
debugPrint('failed to import key=$key, value=$value is not an int');
debugPrint('failed to import key=$key, value=$newValue is not an int');
}
break;
case subtitleFontSizeKey:
case infoMapZoomKey:
if (value is double) {
_prefs!.setDouble(key, value);
if (newValue is double) {
settingsStore.setDouble(key, newValue);
} else {
debugPrint('failed to import key=$key, value=$value is not a double');
debugPrint('failed to import key=$key, value=$newValue is not a double');
}
break;
case isInstalledAppAccessAllowedKey:
@ -634,10 +651,10 @@ class Settings extends ChangeNotifier {
case subtitleShowOutlineKey:
case saveSearchHistoryKey:
case filePickerShowHiddenFilesKey:
if (value is bool) {
_prefs!.setBool(key, value);
if (newValue is bool) {
settingsStore.setBool(key, newValue);
} else {
debugPrint('failed to import key=$key, value=$value is not a bool');
debugPrint('failed to import key=$key, value=$newValue is not a bool');
}
break;
case localeKey:
@ -657,12 +674,13 @@ class Settings extends ChangeNotifier {
case unitSystemKey:
case accessibilityAnimationsKey:
case timeToTakeActionKey:
if (value is String) {
_prefs!.setString(key, value);
if (newValue is String) {
settingsStore.setString(key, newValue);
} else {
debugPrint('failed to import key=$key, value=$value is not a string');
debugPrint('failed to import key=$key, value=$newValue is not a string');
}
break;
case confirmationDialogsKey:
case drawerTypeBookmarksKey:
case drawerAlbumBookmarksKey:
case drawerPageBookmarksKey:
@ -672,17 +690,29 @@ class Settings extends ChangeNotifier {
case collectionSelectionQuickActionsKey:
case viewerQuickActionsKey:
case videoQuickActionsKey:
if (value is List) {
_prefs!.setStringList(key, value.cast<String>());
if (newValue is List) {
settingsStore.setStringList(key, newValue.cast<String>());
} else {
debugPrint('failed to import key=$key, value=$value is not a list');
debugPrint('failed to import key=$key, value=$newValue is not a list');
}
break;
}
}
_updateStreamController.add(key);
if (oldValue != newValue) {
_updateStreamController.add(SettingsChangedEvent(key, oldValue, newValue));
}
});
notifyListeners();
}
}
}
@immutable
class SettingsChangedEvent {
final String key;
final dynamic oldValue;
final dynamic newValue;
// old and new values as stored, e.g. `List<String>` for collections
const SettingsChangedEvent(this.key, this.oldValue, this.newValue);
}

View file

@ -0,0 +1,37 @@
abstract class SettingsStore {
bool get initialized;
Future<void> init();
Future<bool> clear();
Future<bool> remove(String key);
// get
Set<String> getKeys();
Object? get(String key);
bool? getBool(String key);
int? getInt(String key);
double? getDouble(String key);
String? getString(String key);
List<String>? getStringList(String key);
// set
Future<bool> setBool(String key, bool value);
Future<bool> setInt(String key, int value);
Future<bool> setDouble(String key, double value);
Future<bool> setString(String key, String value);
Future<bool> setStringList(String key, List<String> value);
}

View file

@ -0,0 +1,65 @@
import 'package:aves/model/settings/store/store.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class SharedPrefSettingsStore implements SettingsStore {
static SharedPreferences? _prefs;
@override
bool get initialized => _prefs != null;
@override
Future<void> init() async {
try {
_prefs = await SharedPreferences.getInstance();
} catch (error, stack) {
debugPrint('$runtimeType init error=$error\n$stack');
}
}
@override
Future<bool> clear() => _prefs!.clear();
@override
Future<bool> remove(String key) => _prefs!.remove(key);
// get
@override
Set<String> getKeys() => _prefs!.getKeys();
@override
Object? get(String key) => _prefs!.get(key);
@override
bool? getBool(String key) => _prefs!.getBool(key);
@override
int? getInt(String key) => _prefs!.getInt(key);
@override
double? getDouble(String key) => _prefs!.getDouble(key);
@override
String? getString(String key) => _prefs!.getString(key);
@override
List<String>? getStringList(String key) => _prefs!.getStringList(key);
// set
@override
Future<bool> setBool(String key, bool value) => _prefs!.setBool(key, value);
@override
Future<bool> setInt(String key, int value) => _prefs!.setInt(key, value);
@override
Future<bool> setDouble(String key, double value) => _prefs!.setDouble(key, value);
@override
Future<bool> setString(String key, String value) => _prefs!.setString(key, value);
@override
Future<bool> setStringList(String key, List<String> value) => _prefs!.setStringList(key, value);
}

View file

@ -26,56 +26,9 @@ mixin AlbumMixin on SourceBase {
return compareAsciiUpperCase(va, vb);
}
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
String getAlbumDisplayName(BuildContext? context, String dirPath) {
final separator = pContext.separator;
assert(!dirPath.endsWith(separator));
if (context != null) {
final type = androidFileUtils.getAlbumType(dirPath);
if (type == AlbumType.camera) return context.l10n.albumCamera;
if (type == AlbumType.download) return context.l10n.albumDownload;
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
if (type == AlbumType.videoCaptures) return context.l10n.albumVideoCaptures;
}
final dir = VolumeRelativeDirectory.fromPath(dirPath);
if (dir == null) return dirPath;
final relativeDir = dir.relativeDir;
if (relativeDir.isEmpty) {
final volume = androidFileUtils.getStorageVolume(dirPath)!;
return volume.getDescription(context);
}
String unique(String dirPath, Set<String?> others) {
final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) {
final name = pContext.joinAll(['', ...parts.skip(i)]);
final testName = '$separator$name';
if (others.every((item) => !item!.endsWith(testName))) return name;
}
return dirPath;
}
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
if (uniqueNameInDevice.length <= relativeDir.length) {
return uniqueNameInDevice;
}
final volumePath = dir.volumePath;
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
final volume = androidFileUtils.getStorageVolume(dirPath)!;
if (volume.isPrimary) {
return uniqueNameInVolume;
} else {
return '$uniqueNameInVolume (${volume.getDescription(context)})';
}
void _onAlbumChanged() {
invalidateAlbumDisplayNames();
eventBus.fire(AlbumsChangedEvent());
}
Map<String, AvesEntry?> getAlbumEntries() {
@ -109,7 +62,7 @@ mixin AlbumMixin on SourceBase {
void addDirectories(Set<String?> albums) {
if (!_directories.containsAll(albums)) {
_directories.addAll(albums);
_notifyAlbumChange();
_onAlbumChanged();
}
}
@ -117,7 +70,7 @@ mixin AlbumMixin on SourceBase {
final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet();
if (emptyAlbums.isNotEmpty) {
_directories.removeAll(emptyAlbums);
_notifyAlbumChange();
_onAlbumChanged();
invalidateAlbumFilterSummary(directories: emptyAlbums);
final bookmarks = settings.drawerAlbumBookmarks;
@ -166,6 +119,8 @@ mixin AlbumMixin on SourceBase {
return _filterRecentEntryMap.putIfAbsent(filter.album, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
}
// new albums
void createAlbum(String directory) {
_newAlbums.add(directory);
addDirectories({directory});
@ -181,6 +136,80 @@ mixin AlbumMixin on SourceBase {
void forgetNewAlbums(Set<String> directories) {
_newAlbums.removeAll(directories);
}
// display names
final Map<String, String> _albumDisplayNamesWithContext = {}, _albumDisplayNamesWithoutContext = {};
void invalidateAlbumDisplayNames() {
_albumDisplayNamesWithContext.clear();
_albumDisplayNamesWithoutContext.clear();
}
String _computeDisplayName(BuildContext? context, String dirPath) {
final separator = pContext.separator;
assert(!dirPath.endsWith(separator));
if (context != null) {
final type = androidFileUtils.getAlbumType(dirPath);
switch (type) {
case AlbumType.camera:
return context.l10n.albumCamera;
case AlbumType.download:
return context.l10n.albumDownload;
case AlbumType.screenshots:
return context.l10n.albumScreenshots;
case AlbumType.screenRecordings:
return context.l10n.albumScreenRecordings;
case AlbumType.videoCaptures:
return context.l10n.albumVideoCaptures;
case AlbumType.regular:
case AlbumType.app:
break;
}
}
final dir = VolumeRelativeDirectory.fromPath(dirPath);
if (dir == null) return dirPath;
final relativeDir = dir.relativeDir;
if (relativeDir.isEmpty) {
final volume = androidFileUtils.getStorageVolume(dirPath)!;
return volume.getDescription(context);
}
String unique(String dirPath, Set<String?> others) {
final parts = pContext.split(dirPath);
for (var i = parts.length - 1; i > 0; i--) {
final name = pContext.joinAll(['', ...parts.skip(i)]);
final testName = '$separator$name';
if (others.every((item) => !item!.endsWith(testName))) return name;
}
return dirPath;
}
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
if (uniqueNameInDevice.length <= relativeDir.length) {
return uniqueNameInDevice;
}
final volumePath = dir.volumePath;
String trimVolumePath(String? path) => path!.substring(dir.volumePath.length);
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path!.startsWith(volumePath)).map(trimVolumePath).toSet();
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
final volume = androidFileUtils.getStorageVolume(dirPath)!;
if (volume.isPrimary) {
return uniqueNameInVolume;
} else {
return '$uniqueNameInVolume (${volume.getDescription(context)})';
}
}
String getAlbumDisplayName(BuildContext? context, String dirPath) {
final names = (context != null ? _albumDisplayNamesWithContext : _albumDisplayNamesWithoutContext);
return names.putIfAbsent(dirPath, () => _computeDisplayName(context, dirPath));
}
}
class AlbumsChangedEvent {}

View file

@ -2,12 +2,12 @@ import 'package:flutter/foundation.dart';
class AnalysisController {
final bool canStartService, force;
final List<int>? contentIds;
final List<int>? entryIds;
final ValueNotifier<bool> stopSignal;
AnalysisController({
this.canStartService = true,
this.contentIds,
this.entryIds,
this.force = false,
ValueNotifier<bool>? stopSignal,
}) : stopSignal = stopSignal ?? ValueNotifier(false);

View file

@ -10,6 +10,7 @@ import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/rating.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/events.dart';
@ -31,7 +32,7 @@ class CollectionLens with ChangeNotifier {
final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier();
final List<StreamSubscription> _subscriptions = [];
int? id;
bool listenToSource;
bool listenToSource, groupBursts;
List<AvesEntry>? fixedSelection;
List<AvesEntry> _filteredSortedEntries = [];
@ -43,6 +44,7 @@ class CollectionLens with ChangeNotifier {
Set<CollectionFilter?>? filters,
this.id,
this.listenToSource = true,
this.groupBursts = true,
this.fixedSelection,
}) : filters = (filters ?? {}).whereNotNull().toSet(),
sectionFactor = settings.collectionSectionFactor,
@ -53,9 +55,18 @@ class CollectionLens with ChangeNotifier {
_subscriptions.add(sourceEvents.on<EntryAddedEvent>().listen((e) => _onEntryAdded(e.entries)));
_subscriptions.add(sourceEvents.on<EntryRemovedEvent>().listen((e) => _onEntryRemoved(e.entries)));
_subscriptions.add(sourceEvents.on<EntryMovedEvent>().listen((e) {
if (e.type == MoveType.move) {
// refreshing copied items is already handled via `EntryAddedEvent`s
_refresh();
switch (e.type) {
case MoveType.copy:
case MoveType.export:
// refreshing new items is already handled via `EntryAddedEvent`s
break;
case MoveType.move:
case MoveType.fromBin:
_refresh();
break;
case MoveType.toBin:
_onEntryRemoved(e.entries);
break;
}
}));
_subscriptions.add(sourceEvents.on<EntryRefreshedEvent>().listen((e) => _refresh()));
@ -68,7 +79,12 @@ class CollectionLens with ChangeNotifier {
}));
favourites.addListener(_onFavouritesChanged);
}
settings.addListener(_onSettingsChanged);
_subscriptions.add(settings.updateStream
.where((event) => [
Settings.collectionSortFactorKey,
Settings.collectionGroupFactorKey,
].contains(event.key))
.listen((_) => _onSettingsChanged()));
_refresh();
}
@ -78,7 +94,6 @@ class CollectionLens with ChangeNotifier {
..forEach((sub) => sub.cancel())
..clear();
favourites.removeListener(_onFavouritesChanged);
settings.removeListener(_onSettingsChanged);
super.dispose();
}
@ -160,10 +175,8 @@ class CollectionLens with ChangeNotifier {
filterChangeNotifier.notifyListeners();
}
final bool groupBursts = true;
void _applyFilters() {
final entries = fixedSelection ?? source.visibleEntries;
final entries = fixedSelection ?? (filters.contains(TrashFilter.instance) ? source.trashedEntries : source.visibleEntries);
_filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry))));
if (groupBursts) {

View file

@ -8,6 +8,8 @@ import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/filters/trash.dart';
import 'package:aves/model/metadata/trash.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/analysis_controller.dart';
@ -15,6 +17,7 @@ import 'package:aves/model/source/enums.dart';
import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/services/analysis_service.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
@ -22,13 +25,17 @@ import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
enum SourceInitializationState { none, directory, full }
mixin SourceBase {
EventBus get eventBus;
Map<int?, AvesEntry> get entryById;
Map<int, AvesEntry> get entryById;
Set<AvesEntry> get visibleEntries;
Set<AvesEntry> get trashedEntries;
List<AvesEntry> get sortedEntriesByDate;
ValueNotifier<SourceState> stateNotifier = ValueNotifier(SourceState.ready);
@ -38,22 +45,33 @@ mixin SourceBase {
void setProgress({required int done, required int total}) => progressNotifier.value = ProgressEvent(done: done, total: total);
}
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin, TrashMixin {
CollectionSource() {
settings.updateStream.where((event) => event.key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames());
settings.updateStream.where((event) => event.key == Settings.hiddenFiltersKey).listen((event) {
final oldValue = event.oldValue;
if (oldValue is List<String>?) {
final oldHiddenFilters = (oldValue ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
_onFilterVisibilityChanged(oldHiddenFilters, settings.hiddenFilters);
}
});
}
final EventBus _eventBus = EventBus();
@override
EventBus get eventBus => _eventBus;
final Map<int?, AvesEntry> _entryById = {};
final Map<int, AvesEntry> _entryById = {};
@override
Map<int?, AvesEntry> get entryById => Map.unmodifiable(_entryById);
Map<int, AvesEntry> get entryById => Map.unmodifiable(_entryById);
final Set<AvesEntry> _rawEntries = {};
Set<AvesEntry> get allEntries => Set.unmodifiable(_rawEntries);
Set<AvesEntry>? _visibleEntries;
Set<AvesEntry>? _visibleEntries, _trashedEntries;
@override
Set<AvesEntry> get visibleEntries {
@ -61,6 +79,12 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _visibleEntries!;
}
@override
Set<AvesEntry> get trashedEntries {
_trashedEntries ??= Set.unmodifiable(_applyTrashFilter(_rawEntries));
return _trashedEntries!;
}
List<AvesEntry>? _sortedEntriesByDate;
@override
@ -69,6 +93,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return _sortedEntriesByDate!;
}
// known date by entry ID
late Map<int?, int?> _savedDates;
Future<void> loadDates() async {
@ -76,12 +101,20 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
final hiddenFilters = settings.hiddenFilters;
return hiddenFilters.isEmpty ? entries : entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
final hiddenFilters = {
TrashFilter.instance,
...settings.hiddenFilters,
};
return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
}
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
return entries.where(TrashFilter.instance.test);
}
void _invalidate([Set<AvesEntry>? entries]) {
_visibleEntries = null;
_trashedEntries = null;
_sortedEntriesByDate = null;
invalidateAlbumFilterSummary(entries: entries);
invalidateCountryFilterSummary(entries: entries);
@ -100,14 +133,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
void addEntries(Set<AvesEntry> entries) {
if (entries.isEmpty) return;
final newIdMapEntries = Map.fromEntries(entries.map((v) => MapEntry(v.contentId, v)));
final newIdMapEntries = Map.fromEntries(entries.map((entry) => MapEntry(entry.id, entry)));
if (_rawEntries.isNotEmpty) {
final newContentIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newContentIds.contains(entry.contentId));
final newIds = newIdMapEntries.keys.toSet();
_rawEntries.removeWhere((entry) => newIds.contains(entry.id));
}
entries.where((entry) => entry.catalogDateMillis == null).forEach((entry) {
entry.catalogDateMillis = _savedDates[entry.contentId];
entry.catalogDateMillis = _savedDates[entry.id];
});
_entryById.addAll(newIdMapEntries);
@ -118,14 +151,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
eventBus.fire(EntryAddedEvent(entries));
}
Future<void> removeEntries(Set<String> uris) async {
Future<void> removeEntries(Set<String> uris, {required bool includeTrash}) async {
if (uris.isEmpty) return;
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
await favourites.remove(entries);
await covers.removeEntries(entries);
await metadataDb.removeVideoPlayback(entries.map((entry) => entry.contentId).whereNotNull().toSet());
entries.forEach((v) => _entryById.remove(v.contentId));
final entries = _rawEntries.where((entry) => uris.contains(entry.uri)).toSet();
if (!includeTrash) {
entries.removeWhere(TrashFilter.instance.test);
}
if (entries.isEmpty) return;
final ids = entries.map((entry) => entry.id).toSet();
await favourites.removeIds(ids);
await covers.removeIds(ids);
await metadataDb.removeIds(ids);
ids.forEach((id) => _entryById.remove);
_rawEntries.removeAll(entries);
updateDerivedFilters(entries);
eventBus.fire(EntryRemovedEvent(entries));
@ -142,27 +182,51 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
final oldContentId = entry.contentId!;
final newContentId = newFields['contentId'] as int?;
newFields.keys.forEach((key) {
switch (key) {
case 'contentId':
entry.contentId = newFields['contentId'] as int?;
break;
case 'dateModifiedSecs':
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
break;
case 'path':
entry.path = newFields['path'] as String?;
break;
case 'title':
entry.sourceTitle = newFields['title'] as String?;
break;
case 'trashed':
final trashed = newFields['trashed'] as bool;
entry.trashed = trashed;
entry.trashDetails = trashed
? TrashDetails(
id: entry.id,
path: newFields['trashPath'] as String,
dateMillis: DateTime.now().millisecondsSinceEpoch,
)
: null;
break;
case 'uri':
entry.uri = newFields['uri'] as String;
break;
}
});
if (entry.trashed) {
entry.contentId = null;
entry.uri = 'file://${entry.trashDetails?.path}';
}
entry.contentId = newContentId;
// `dateModifiedSecs` changes when moving entries to another directory,
// but it does not change when renaming the containing directory
if (newFields.containsKey('dateModifiedSecs')) entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
if (newFields.containsKey('path')) entry.path = newFields['path'] as String?;
if (newFields.containsKey('uri')) entry.uri = newFields['uri'] as String;
if (newFields.containsKey('title')) entry.sourceTitle = newFields['title'] as String?;
entry.catalogMetadata = entry.catalogMetadata?.copyWith(contentId: newContentId);
entry.addressDetails = entry.addressDetails?.copyWith(contentId: newContentId);
await covers.moveEntry(entry, persist: persist);
if (persist) {
await metadataDb.updateEntryId(oldContentId, entry);
await metadataDb.updateMetadataId(oldContentId, entry.catalogMetadata);
await metadataDb.updateAddressId(oldContentId, entry.addressDetails);
await favourites.moveEntry(oldContentId, entry);
await covers.moveEntry(oldContentId, entry);
await metadataDb.updateVideoPlaybackId(oldContentId, entry.contentId);
final id = entry.id;
await metadataDb.updateEntry(id, entry);
await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata);
await metadataDb.updateAddress(id, entry.addressDetails);
await metadataDb.updateTrash(id, entry.trashDetails);
}
}
@ -198,42 +262,40 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
return success;
}
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> todoEntries, Set<MoveOpEvent> movedOps) async {
Future<void> renameAlbum(String sourceAlbum, String destinationAlbum, Set<AvesEntry> entries, Set<MoveOpEvent> movedOps) async {
final oldFilter = AlbumFilter(sourceAlbum, null);
final bookmarked = settings.drawerAlbumBookmarks?.contains(sourceAlbum) == true;
final newFilter = AlbumFilter(destinationAlbum, null);
final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum);
final pinned = settings.pinnedFilters.contains(oldFilter);
final oldCoverContentId = covers.coverContentId(oldFilter);
final coverEntry = oldCoverContentId != null ? todoEntries.firstWhereOrNull((entry) => entry.contentId == oldCoverContentId) : null;
await covers.set(newFilter, covers.coverEntryId(oldFilter));
renameNewAlbum(sourceAlbum, destinationAlbum);
await updateAfterMove(
todoEntries: todoEntries,
copy: false,
destinationAlbum: destinationAlbum,
todoEntries: entries,
moveType: MoveType.move,
destinationAlbums: {destinationAlbum},
movedOps: movedOps,
);
// restore bookmark, pin and cover, as the obsolete album got removed and its associated state cleaned
final newFilter = AlbumFilter(destinationAlbum, null);
if (bookmarked) {
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..add(destinationAlbum);
// restore bookmark and pin, as the obsolete album got removed and its associated state cleaned
if (bookmark != null && bookmark != -1) {
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..insert(bookmark, destinationAlbum);
}
if (pinned) {
settings.pinnedFilters = settings.pinnedFilters..add(newFilter);
}
if (coverEntry != null) {
await covers.set(newFilter, coverEntry.contentId);
}
}
Future<void> updateAfterMove({
required Set<AvesEntry> todoEntries,
required bool copy,
required String destinationAlbum,
required MoveType moveType,
required Set<String> destinationAlbums,
required Set<MoveOpEvent> movedOps,
}) async {
if (movedOps.isEmpty) return;
final fromAlbums = <String?>{};
final movedEntries = <AvesEntry>{};
final copy = moveType == MoveType.copy;
if (copy) {
movedOps.forEach((movedOp) {
final sourceUri = movedOp.uri;
@ -242,6 +304,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (sourceEntry != null) {
fromAlbums.add(sourceEntry.directory);
movedEntries.add(sourceEntry.copyWith(
id: metadataDb.nextId,
uri: newFields['uri'] as String?,
path: newFields['path'] as String?,
contentId: newFields['contentId'] as int?,
@ -254,7 +317,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
});
await metadataDb.saveEntries(movedEntries);
await metadataDb.saveMetadata(movedEntries.map((entry) => entry.catalogMetadata).whereNotNull().toSet());
await metadataDb.saveCatalogMetadata(movedEntries.map((entry) => entry.catalogMetadata).whereNotNull().toSet());
await metadataDb.saveAddresses(movedEntries.map((entry) => entry.addressDetails).whereNotNull().toSet());
} else {
await Future.forEach<MoveOpEvent>(movedOps, (movedOp) async {
@ -263,7 +326,11 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
final sourceUri = movedOp.uri;
final entry = todoEntries.firstWhereOrNull((entry) => entry.uri == sourceUri);
if (entry != null) {
fromAlbums.add(entry.directory);
if (moveType == MoveType.fromBin) {
newFields['trashed'] = false;
} else {
fromAlbums.add(entry.directory);
}
movedEntries.add(entry);
await _moveEntry(entry, newFields, persist: true);
}
@ -275,18 +342,22 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
addEntries(movedEntries);
} else {
cleanEmptyAlbums(fromAlbums);
addDirectories({destinationAlbum});
if (moveType != MoveType.toBin) {
addDirectories(destinationAlbums);
}
}
invalidateAlbumFilterSummary(directories: fromAlbums);
_invalidate(movedEntries);
eventBus.fire(EntryMovedEvent(copy ? MoveType.copy : MoveType.move, movedEntries));
eventBus.fire(EntryMovedEvent(moveType, movedEntries));
}
bool get initialized => false;
SourceInitializationState get initState => SourceInitializationState.none;
Future<void> init();
Future<void> refresh({AnalysisController? analysisController});
Future<void> init({
AnalysisController? analysisController,
String? directory,
bool loadTopEntriesFirst = false,
});
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController});
@ -294,13 +365,13 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
await entry.refresh(background: false, persist: true, dataTypes: dataTypes, geocoderLocale: settings.appliedLocale);
// update/delete in DB
final contentId = entry.contentId!;
final id = entry.id;
if (dataTypes.contains(EntryDataType.catalog)) {
await metadataDb.updateMetadataId(contentId, entry.catalogMetadata);
await metadataDb.updateCatalogMetadata(id, entry.catalogMetadata);
onCatalogMetadataChanged();
}
if (dataTypes.contains(EntryDataType.address)) {
await metadataDb.updateAddressId(contentId, entry.addressDetails);
await metadataDb.updateAddress(id, entry.addressDetails);
onAddressMetadataChanged();
}
@ -334,7 +405,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
if (startAnalysisService) {
await AnalysisService.startService(
force: force,
contentIds: entries?.map((entry) => entry.contentId).whereNotNull().toList(),
entryIds: entries?.map((entry) => entry.id).toList(),
);
} else {
await catalogEntries(_analysisController, todoEntries);
@ -373,28 +444,21 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
}
AvesEntry? coverEntry(CollectionFilter filter) {
final contentId = covers.coverContentId(filter);
if (contentId != null) {
final entry = visibleEntries.firstWhereOrNull((entry) => entry.contentId == contentId);
final id = covers.coverEntryId(filter);
if (id != null) {
final entry = visibleEntries.firstWhereOrNull((entry) => entry.id == id);
if (entry != null) return entry;
}
return recentEntry(filter);
}
void changeFilterVisibility(Set<CollectionFilter> filters, bool visible) {
final hiddenFilters = settings.hiddenFilters;
if (visible) {
hiddenFilters.removeAll(filters);
} else {
hiddenFilters.addAll(filters);
settings.searchHistory = settings.searchHistory..removeWhere(filters.contains);
}
settings.hiddenFilters = hiddenFilters;
void _onFilterVisibilityChanged(Set<CollectionFilter> oldHiddenFilters, Set<CollectionFilter> currentHiddenFilters) {
updateDerivedFilters();
eventBus.fire(FilterVisibilityChangedEvent(filters, visible));
eventBus.fire(const FilterVisibilityChangedEvent());
if (visible) {
final candidateEntries = visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
final newlyVisibleFilters = oldHiddenFilters.whereNot(currentHiddenFilters.contains).toSet();
if (newlyVisibleFilters.isNotEmpty) {
final candidateEntries = visibleEntries.where((entry) => newlyVisibleFilters.any((f) => f.test(entry))).toSet();
analyze(null, entries: candidateEntries);
}
}

View file

@ -1,6 +1,5 @@
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:flutter/foundation.dart';
@immutable
@ -34,10 +33,7 @@ class EntryRefreshedEvent {
@immutable
class FilterVisibilityChangedEvent {
final Set<CollectionFilter> filters;
final bool visible;
const FilterVisibilityChangedEvent(this.filters, this.visible);
const FilterVisibilityChangedEvent();
}
@immutable

View file

@ -20,10 +20,10 @@ mixin LocationMixin on SourceBase {
List<String> sortedCountries = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses() async {
final saved = await metadataDb.loadAllAddresses();
Future<void> loadAddresses({Set<int>? ids}) async {
final saved = await (ids != null ? metadataDb.loadAddressesById(ids) : metadataDb.loadAddresses());
final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.addressDetails = metadata);
saved.forEach((metadata) => idMap[metadata.id]?.addressDetails = metadata);
onAddressMetadataChanged();
}
@ -31,7 +31,7 @@ mixin LocationMixin on SourceBase {
await _locateCountries(controller, candidateEntries);
await _locatePlaces(controller, candidateEntries);
final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.contentId).whereNotNull().toSet();
final unlocatedIds = candidateEntries.where((entry) => !entry.hasGps).map((entry) => entry.id).toSet();
if (unlocatedIds.isNotEmpty) {
await metadataDb.removeIds(unlocatedIds, dataTypes: {EntryDataType.address});
onAddressMetadataChanged();
@ -115,7 +115,7 @@ mixin LocationMixin on SourceBase {
for (final entry in todo) {
final latLng = approximateLatLng(entry);
if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
entry.addressDetails = knownLocations[latLng]?.copyWith(id: entry.id);
} else {
await entry.locatePlace(background: true, force: force, geocoderLocale: settings.appliedLocale);
// it is intended to insert `null` if the geocoder failed,

View file

@ -14,13 +14,31 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
class MediaStoreSource extends CollectionSource {
bool _initialized = false;
SourceInitializationState _initState = SourceInitializationState.none;
@override
bool get initialized => _initialized;
SourceInitializationState get initState => _initState;
@override
Future<void> init() async {
Future<void> init({
AnalysisController? analysisController,
String? directory,
bool loadTopEntriesFirst = false,
}) async {
if (_initState == SourceInitializationState.none) {
await _loadEssentials();
}
if (_initState != SourceInitializationState.full) {
_initState = directory != null ? SourceInitializationState.directory : SourceInitializationState.full;
}
unawaited(_loadEntries(
analysisController: analysisController,
directory: directory,
loadTopEntriesFirst: loadTopEntriesFirst,
));
}
Future<void> _loadEssentials() async {
final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading;
await metadataDb.init();
@ -33,52 +51,94 @@ class MediaStoreSource extends CollectionSource {
// clear catalog metadata to get correct date/times when moving to a different time zone
debugPrint('$runtimeType clear catalog metadata to get correct date/times');
await metadataDb.clearDates();
await metadataDb.clearMetadataEntries();
await metadataDb.clearCatalogMetadata();
settings.catalogTimeZone = currentTimeZone;
}
}
await loadDates();
_initialized = true;
debugPrint('$runtimeType init complete in ${stopwatch.elapsed.inMilliseconds}ms');
debugPrint('$runtimeType load essentials complete in ${stopwatch.elapsed.inMilliseconds}ms');
}
@override
Future<void> refresh({AnalysisController? analysisController}) async {
assert(_initialized);
Future<void> _loadEntries({
AnalysisController? analysisController,
String? directory,
required bool loadTopEntriesFirst,
}) async {
debugPrint('$runtimeType refresh start');
final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading;
clearEntries();
final Set<AvesEntry> topEntries = {};
if (loadTopEntriesFirst) {
final topIds = settings.topEntryIds;
if (topIds != null) {
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries');
topEntries.addAll(await metadataDb.loadEntriesById(topIds));
addEntries(topEntries);
}
}
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
final oldEntries = await metadataDb.loadAllEntries();
final knownEntries = await metadataDb.loadEntries(directory: directory);
final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet();
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
final knownDateById = Map.fromEntries(oldEntries.map((entry) => MapEntry(entry.contentId!, entry.dateModifiedSecs!)));
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(knownDateById.keys.toList())).toSet();
oldEntries.removeWhere((entry) => obsoleteContentIds.contains(entry.contentId));
final knownDateByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.dateModifiedSecs)));
final knownContentIds = knownDateByContentId.keys.toList();
final removedContentIds = (await mediaStoreService.checkObsoleteContentIds(knownContentIds)).toSet();
if (topEntries.isNotEmpty) {
final removedTopEntries = topEntries.where((entry) => removedContentIds.contains(entry.contentId));
await removeEntries(removedTopEntries.map((entry) => entry.uri).toSet(), includeTrash: false);
}
final removedEntries = knownEntries.where((entry) => removedContentIds.contains(entry.contentId)).toSet();
knownEntries.removeAll(removedEntries);
// show known entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} add known entries');
addEntries(oldEntries);
addEntries(knownEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
await loadCatalogMetadata();
await loadAddresses();
updateDerivedFilters();
if (directory != null) {
final ids = knownLiveEntries.map((entry) => entry.id).toSet();
await loadCatalogMetadata(ids: ids);
await loadAddresses(ids: ids);
} else {
await loadCatalogMetadata();
await loadAddresses();
updateDerivedFilters();
}
// clean up obsolete entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
await metadataDb.removeIds(obsoleteContentIds);
if (removedEntries.isNotEmpty) {
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
await metadataDb.removeIds(removedEntries.map((entry) => entry.id));
}
if (directory != null) {
// trash
await loadTrashDetails();
unawaited(deleteExpiredTrash().then(
(deletedUris) {
if (deletedUris.isNotEmpty) {
debugPrint('evicted ${deletedUris.length} expired items from the trash');
removeEntries(deletedUris, includeTrash: true);
}
},
onError: (error) => debugPrint('failed to evict expired trash error=$error'),
));
}
// verify paths because some apps move files without updating their `last modified date`
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete paths');
final knownPathById = Map.fromEntries(allEntries.map((entry) => MapEntry(entry.contentId!, entry.path)));
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathById)).toSet();
final knownPathByContentId = Map.fromEntries(knownLiveEntries.map((entry) => MapEntry(entry.contentId, entry.path)));
final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathByContentId)).toSet();
movedContentIds.forEach((contentId) {
// make obsolete by resetting its modified date
knownDateById[contentId] = 0;
knownDateByContentId[contentId] = 0;
});
// fetch new entries
// fetch new & modified entries
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch new entries');
// refresh after the first 10 entries, then after 100 more, then every 1000 entries
var refreshCount = 10;
@ -90,8 +150,13 @@ class MediaStoreSource extends CollectionSource {
pendingNewEntries.clear();
}
mediaStoreService.getEntries(knownDateById).listen(
mediaStoreService.getEntries(knownDateByContentId, directory: directory).listen(
(entry) {
// when discovering modified entry with known content ID,
// reuse known entry ID to overwrite it while preserving favourites, etc.
final contentId = entry.contentId;
entry.id = (knownContentIds.contains(contentId) ? knownLiveEntries.firstWhereOrNull((entry) => entry.contentId == contentId)?.id : null) ?? metadataDb.nextId;
pendingNewEntries.add(entry);
if (pendingNewEntries.length >= refreshCount) {
refreshCount = min(refreshCount * 10, refreshCountMax);
@ -111,14 +176,15 @@ class MediaStoreSource extends CollectionSource {
updateDirectories();
}
debugPrint('$runtimeType refresh ${stopwatch.elapsed} analyze');
Set<AvesEntry>? analysisEntries;
final analysisIds = analysisController?.contentIds;
final analysisIds = analysisController?.entryIds;
if (analysisIds != null) {
analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.contentId)).toSet();
analysisEntries = visibleEntries.where((entry) => analysisIds.contains(entry.id)).toSet();
}
await analyze(analysisController, entries: analysisEntries);
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${oldEntries.length} known, ${allNewEntries.length} new, ${obsoleteContentIds.length} obsolete');
debugPrint('$runtimeType refresh ${stopwatch.elapsed} done for ${knownEntries.length} known, ${allNewEntries.length} new, ${removedEntries.length} removed');
},
onError: (error) => debugPrint('$runtimeType stream error=$error'),
);
@ -131,7 +197,7 @@ class MediaStoreSource extends CollectionSource {
// sometimes yields an entry with its temporary path: `/data/sec/camera/!@#$%^..._temp.jpg`
@override
Future<Set<String>> refreshUris(Set<String> changedUris, {AnalysisController? analysisController}) async {
if (!_initialized || !isMonitoring) return changedUris;
if (_initState == SourceInitializationState.none || !isMonitoring) return changedUris;
debugPrint('$runtimeType refreshUris ${changedUris.length} uris');
final uriByContentId = Map.fromEntries(changedUris.map((uri) {
@ -147,7 +213,7 @@ class MediaStoreSource extends CollectionSource {
// clean up obsolete entries
final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds(uriByContentId.keys.toList())).toSet();
final obsoleteUris = obsoleteContentIds.map((contentId) => uriByContentId[contentId]).whereNotNull().toSet();
await removeEntries(obsoleteUris);
await removeEntries(obsoleteUris, includeTrash: false);
obsoleteContentIds.forEach(uriByContentId.remove);
// fetch new entries
@ -165,6 +231,7 @@ class MediaStoreSource extends CollectionSource {
final newPath = sourceEntry.path;
final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null;
if (volume != null) {
sourceEntry.id = existingEntry?.id ?? metadataDb.nextId;
newEntries.add(sourceEntry);
final existingDirectory = existingEntry?.directory;
if (existingDirectory != null) {

View file

@ -14,10 +14,10 @@ mixin TagMixin on SourceBase {
List<String> sortedTags = List.unmodifiable([]);
Future<void> loadCatalogMetadata() async {
final saved = await metadataDb.loadAllMetadataEntries();
Future<void> loadCatalogMetadata({Set<int>? ids}) async {
final saved = await (ids != null ? metadataDb.loadCatalogMetadataById(ids) : metadataDb.loadCatalogMetadata());
final idMap = entryById;
saved.forEach((metadata) => idMap[metadata.contentId]?.catalogMetadata = metadata);
saved.forEach((metadata) => idMap[metadata.id]?.catalogMetadata = metadata);
onCatalogMetadataChanged();
}
@ -42,7 +42,7 @@ mixin TagMixin on SourceBase {
if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata!);
if (newMetadata.length >= commitCountThreshold) {
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
await metadataDb.saveCatalogMetadata(Set.unmodifiable(newMetadata));
onCatalogMetadataChanged();
newMetadata.clear();
}
@ -53,7 +53,7 @@ mixin TagMixin on SourceBase {
}
setProgress(done: ++progressDone, total: progressTotal);
}
await metadataDb.saveMetadata(Set.unmodifiable(newMetadata));
await metadataDb.saveCatalogMetadata(Set.unmodifiable(newMetadata));
onCatalogMetadataChanged();
}

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