From bc6d75e92820b7108c794f5016717c7cfab8ded0 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 16 Feb 2023 19:49:33 +0100 Subject: [PATCH] #114 vaults --- CHANGELOG.md | 2 + android/app/build.gradle | 6 +- android/app/src/main/AndroidManifest.xml | 37 +- .../aves/HomeWidgetConfigureActivity.kt | 11 +- .../deckers/thibault/aves/MainActivity.kt | 17 +- .../thibault/aves/WallpaperActivity.kt | 61 +- .../aves/channel/calls/DeviceHandler.kt | 7 +- .../aves/channel/calls/SecurityHandler.kt | 79 ++ .../aves/channel/calls/StorageHandler.kt | 6 + .../calls/fetchers/ThumbnailFetcher.kt | 2 +- .../calls/window/ActivityWindowHandler.kt | 12 +- .../calls/window/ServiceWindowHandler.kt | 4 + .../channel/calls/window/WindowHandler.kt | 3 + .../deckers/thibault/aves/metadata/XMP.kt | 5 + .../thibault/aves/model/SourceEntry.kt | 26 +- .../model/provider/ContentImageProvider.kt | 1 + .../aves/model/provider/FileImageProvider.kt | 16 +- .../model/provider/MediaStoreImageProvider.kt | 43 +- .../deckers/thibault/aves/utils/MimeTypes.kt | 39 +- .../thibault/aves/utils/PermissionManager.kt | 2 +- .../thibault/aves/utils/StorageUtils.kt | 20 +- .../main/res/xml/data_extraction_rules.xml | 37 + .../src/main/res/xml/full_backup_content.xml | 18 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- lib/app_mode.dart | 5 + lib/l10n/app_en.arb | 89 +- lib/model/actions/chip_actions.dart | 5 + lib/model/actions/chip_set_actions.dart | 26 +- lib/model/covers.dart | 3 + lib/model/db/db_metadata.dart | 33 +- lib/model/db/db_metadata_sqflite.dart | 95 ++- lib/model/db/db_metadata_sqflite_upgrade.dart | 17 + lib/model/device.dart | 14 +- lib/model/entry.dart | 19 +- lib/model/favourites.dart | 2 +- lib/model/filters/album.dart | 1 + lib/model/multipage.dart | 1 + lib/model/settings/defaults.dart | 5 +- lib/model/settings/enums/enums.dart | 2 +- lib/model/settings/settings.dart | 26 +- lib/model/source/album.dart | 28 +- lib/model/source/collection_source.dart | 45 +- lib/model/source/media_store_source.dart | 50 +- lib/model/vaults/details.dart | 57 ++ lib/model/vaults/enums.dart | 17 + lib/model/vaults/vaults.dart | 240 ++++++ lib/services/common/services.dart | 3 + lib/services/device_service.dart | 6 +- lib/services/security_service.dart | 39 + lib/services/storage_service.dart | 13 + lib/services/window_service.dart | 13 + lib/theme/icons.dart | 5 + lib/utils/android_file_utils.dart | 34 +- lib/utils/collection_utils.dart | 10 + lib/utils/dependencies.dart | 41 +- .../collection/entry_set_action_delegate.dart | 31 +- .../quick_choosers/move_button.dart | 7 +- .../common/action_mixins/entry_storage.dart | 4 +- .../common/action_mixins/feedback.dart | 36 +- .../common/action_mixins/vault_aware.dart | 32 + .../common/identity/aves_filter_chip.dart | 6 +- lib/widgets/common/identity/aves_icons.dart | 4 +- lib/widgets/debug/database.dart | 30 +- lib/widgets/debug/settings.dart | 1 + .../dialogs/aves_confirmation_dialog.dart | 143 ++-- lib/widgets/dialogs/aves_dialog.dart | 2 +- .../entry_editors/remove_metadata_dialog.dart | 3 +- .../entry_editors/rename_entry_dialog.dart | 3 +- .../filter_editors/create_album_dialog.dart | 3 +- .../filter_editors/edit_vault_dialog.dart | 184 ++++ .../filter_editors/password_dialog.dart | 64 ++ .../dialogs/filter_editors/pin_dialog.dart | 65 ++ .../dialogs/pick_dialogs/album_pick_page.dart | 117 ++- lib/widgets/filter_grids/albums_page.dart | 4 + .../common/action_delegates/album_set.dart | 261 ++++-- .../common/action_delegates/chip.dart | 29 +- .../common/action_delegates/chip_set.dart | 30 +- lib/widgets/filter_grids/common/app_bar.dart | 20 +- .../common/covered_filter_chip.dart | 30 +- lib/widgets/filter_grids/common/enums.dart | 6 +- .../filter_grids/common/filter_grid_page.dart | 99 ++- .../filter_grids/common/filter_tile.dart | 14 +- .../filter_grids/common/list_details.dart | 11 +- .../filter_grids/common/section_keys.dart | 2 + lib/widgets/navigation/drawer/app_drawer.dart | 9 +- lib/widgets/search/search_delegate.dart | 8 +- .../navigation/confirmation_dialogs.dart | 6 + lib/widgets/settings/privacy/privacy.dart | 41 +- lib/widgets/stats/stats_page.dart | 8 +- .../viewer/action/entry_action_delegate.dart | 21 +- lib/widgets/viewer/debug/db.dart | 2 +- lib/widgets/viewer/debug/debug_page.dart | 1 + .../aves_platform_meta/android/build.gradle | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- pubspec.lock | 72 ++ pubspec.yaml | 3 + test/fake/device_service.dart | 2 +- test/fake/media_store_service.dart | 1 + test/fake/metadata_db.dart | 14 +- untranslated.json | 794 +++++++++++++++--- 100 files changed, 2978 insertions(+), 651 deletions(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/main/res/xml/full_backup_content.xml create mode 100644 lib/model/vaults/details.dart create mode 100644 lib/model/vaults/enums.dart create mode 100644 lib/model/vaults/vaults.dart create mode 100644 lib/services/security_service.dart create mode 100644 lib/widgets/common/action_mixins/vault_aware.dart create mode 100644 lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart create mode 100644 lib/widgets/dialogs/filter_editors/password_dialog.dart create mode 100644 lib/widgets/dialogs/filter_editors/pin_dialog.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3462b4f2b..e1fe5cc9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Added +- Vaults - Viewer: overlay details expand/collapse on tap - Viewer: export actions available as quick actions - Slideshow: added settings quick action @@ -14,6 +15,7 @@ All notable changes to this project will be documented in this file. ### Changed +- disabling the recycle bin will delete forever items in it - remember pin status of albums becoming empty - upgraded Flutter to stable v3.7.3 diff --git a/android/app/build.gradle b/android/app/build.gradle index 051231057..b974a5ee6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,6 +18,9 @@ if (localPropertiesFile.exists()) { def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionName = localProperties.getProperty('flutter.versionName') def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" // Keys @@ -181,10 +184,11 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4' implementation 'androidx.core:core-ktx:1.9.0' - implementation 'androidx.exifinterface:exifinterface:1.3.5' + implementation 'androidx.exifinterface:exifinterface:1.3.6' implementation 'androidx.lifecycle:lifecycle-process:2.5.1' implementation 'androidx.media:media:1.6.0' implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.security:security-crypto:1.1.0-alpha04' implementation 'com.caverock:androidsvg-aar:1.4' implementation 'com.commonsware.cwac:document:0.5.0' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5d8bc1ce9..7553cb54f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,7 +19,7 @@ This change eventually prevents building the app with Flutter v3.3.3. android:required="false" /> @@ -32,28 +32,35 @@ This change eventually prevents building the app with Flutter v3.3.3. android:maxSdkVersion="29" tools:ignore="ScopedStorage" /> + + + + + + - - - - - - - - + + + + - - + - - + + @@ -75,12 +82,14 @@ This change eventually prevents building the app with Flutter v3.3.3. android:allowBackup="true" android:appCategory="image" android:banner="@drawable/banner" + android:dataExtractionRules="@xml/data_extraction_rules" + android:fullBackupContent="@xml/full_backup_content" android:fullBackupOnly="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" - tools:targetApi="o"> + tools:targetApi="s"> when (call.method) { "configure" -> { @@ -42,9 +47,9 @@ class HomeWidgetSettingsActivity : MainActivity() { } private fun saveWidget() { - val appWidgetManager = AppWidgetManager.getInstance(context) + val appWidgetManager = AppWidgetManager.getInstance(this) val widgetInfo = appWidgetManager.getAppWidgetOptions(appWidgetId) - HomeWidgetProvider().onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, widgetInfo) + HomeWidgetProvider().onAppWidgetOptionsChanged(this, appWidgetManager, appWidgetId, widgetInfo) val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) setResult(RESULT_OK, intent) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 0f1975177..cfd60ec3e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -25,14 +25,15 @@ import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering import deckers.thibault.aves.utils.FlutterUtils.isSoftwareRenderingRequired import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.getParcelableExtraCompat -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap -open class MainActivity : FlutterActivity() { +open class MainActivity : FlutterFragmentActivity() { private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler private lateinit var intentStreamHandler: IntentStreamHandler @@ -68,8 +69,12 @@ open class MainActivity : FlutterActivity() { // .build() // ) super.onCreate(savedInstanceState) + } - val messenger = flutterEngine!!.dartExecutor + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + val messenger = flutterEngine.dartExecutor // notification: platform -> dart analysisStreamHandler = AnalysisStreamHandler().apply { @@ -99,6 +104,7 @@ open class MainActivity : FlutterActivity() { MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler) MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this)) MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) + MethodChannel(messenger, SecurityHandler.CHANNEL).setMethodCallHandler(SecurityHandler(this)) MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) // - need ContextWrapper MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) @@ -193,6 +199,7 @@ open class MainActivity : FlutterActivity() { } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) when (requestCode) { DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data) DELETE_SINGLE_PERMISSION_REQUEST, @@ -255,7 +262,7 @@ open class MainActivity : FlutterActivity() { Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> { (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri -> // MIME type is optional - val type = intent.type ?: intent.resolveType(context) + val type = intent.type ?: intent.resolveType(this) return hashMapOf( INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW, INTENT_DATA_KEY_MIME_TYPE to type, @@ -325,7 +332,7 @@ open class MainActivity : FlutterActivity() { private fun submitPickedItems(call: MethodCall) { val pickedUris = call.argument>("uris") if (pickedUris != null && pickedUris.isNotEmpty()) { - val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(context, Uri.parse(uriString)) } + val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) } val intent = Intent().apply { val firstUri = toUri(pickedUris.first()) if (pickedUris.size == 1) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt index a4285c3cf..202e73090 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt @@ -1,6 +1,5 @@ package deckers.thibault.aves -import android.app.Activity import android.content.Intent import android.net.Uri import android.os.Build @@ -18,11 +17,12 @@ import deckers.thibault.aves.utils.FlutterUtils import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.getParcelableExtraCompat -import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -class WallpaperActivity : FlutterActivity() { +class WallpaperActivity : FlutterFragmentActivity() { private lateinit var intentDataMap: MutableMap override fun onCreate(savedInstanceState: Bundle?) { @@ -36,8 +36,33 @@ class WallpaperActivity : FlutterActivity() { Log.i(LOG_TAG, "onCreate intent extras=$it") } intentDataMap = extractIntentData(intent) + } - initChannels(this) + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + val messenger = flutterEngine.dartExecutor + + // dart -> platform -> dart + // - need Context + MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this)) + MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this)) + MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(this)) + MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this)) + MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this)) + MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this)) + // - need ContextWrapper + MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) + MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this)) + // - need Activity + MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this)) + + // result streaming: dart -> platform ->->-> dart + // - need Context + StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) } + + // intent handling + // detail fetch: dart -> platform + MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) } } override fun onStart() { @@ -54,32 +79,6 @@ class WallpaperActivity : FlutterActivity() { } } - private fun initChannels(activity: Activity) { - val messenger = flutterEngine!!.dartExecutor - - // dart -> platform -> dart - // - need Context - MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(activity)) - MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(activity)) - MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(activity)) - MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(activity)) - MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(activity)) - MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(activity)) - // - need ContextWrapper - MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(activity)) - MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(activity)) - // - need Activity - MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(activity)) - - // result streaming: dart -> platform ->->-> dart - // - need Context - StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(activity, args) } - - // intent handling - // detail fetch: dart -> platform - MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) } - } - private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getIntentData" -> { @@ -94,7 +93,7 @@ class WallpaperActivity : FlutterActivity() { Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> { (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri -> // MIME type is optional - val type = intent.type ?: intent.resolveType(context) + val type = intent.type ?: intent.resolveType(this) return hashMapOf( MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SET_WALLPAPER, MainActivity.INTENT_DATA_KEY_MIME_TYPE to type, diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index d64a01db9..f35eaaa39 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -21,7 +21,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { when (call.method) { "canManageMedia" -> safe(call, result, ::canManageMedia) "getCapabilities" -> safe(call, result, ::getCapabilities) - "getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone) + "getDefaultTimeZoneRawOffsetMillis" -> safe(call, result, ::getDefaultTimeZoneRawOffsetMillis) "getLocales" -> safe(call, result, ::getLocales) "getPerformanceClass" -> safe(call, result, ::getPerformanceClass) "isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled) @@ -44,6 +44,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { "canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M), "canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S), "canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N), + "canUseCrypto" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP), "hasGeocoder" to Geocoder.isPresent(), "isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S), "showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O), @@ -52,8 +53,8 @@ class DeviceHandler(private val context: Context) : MethodCallHandler { ) } - private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { - result.success(TimeZone.getDefault().id) + private fun getDefaultTimeZoneRawOffsetMillis(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + result.success(TimeZone.getDefault().rawOffset) } private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt new file mode 100644 index 000000000..05a6ba398 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt @@ -0,0 +1,79 @@ +package deckers.thibault.aves.channel.calls + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler + +class SecurityHandler(private val context: Context) : MethodCallHandler { + private var sharedPreferences: SharedPreferences? = null + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "writeValue" -> safe(call, result, ::writeValue) + "readValue" -> safe(call, result, ::readValue) + else -> result.notImplemented() + } + } + + private fun getStore(): SharedPreferences { + if (sharedPreferences == null) { + val mainKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + sharedPreferences = EncryptedSharedPreferences.create( + context, + FILENAME, + mainKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + return sharedPreferences!! + } + + private fun writeValue(call: MethodCall, result: MethodChannel.Result) { + val key = call.argument("key") + val value = call.argument("value") + if (key == null) { + result.error("writeValue-args", "missing arguments", null) + return + } + + with(getStore().edit()) { + when (value) { + is Boolean -> putBoolean(key, value) + is Float -> putFloat(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is String -> putString(key, value) + null -> remove(key) + else -> { + result.error("writeValue-type", "unsupported type for value=$value", null) + return + } + } + apply() + } + result.success(true) + } + + private fun readValue(call: MethodCall, result: MethodChannel.Result) { + val key = call.argument("key") + if (key == null) { + result.error("readValue-args", "missing arguments", null) + return + } + + result.success(getStore().all[key]) + } + + companion object { + const val CHANNEL = "deckers.thibault/aves/security" + const val FILENAME = "secret_shared_prefs" + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index 435705285..1dcc4bd32 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -8,6 +8,7 @@ import androidx.core.os.EnvironmentCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.PermissionManager +import deckers.thibault.aves.utils.StorageUtils import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import io.flutter.plugin.common.MethodCall @@ -25,6 +26,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { "getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) } + "getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) } "getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) } "getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) } "getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) } @@ -88,6 +90,10 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(volumes) } + private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + result.success(StorageUtils.getVaultRoot(context)) + } + private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) { val path = call.argument("path") if (path == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index 3086708ad..752633094 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -109,7 +109,7 @@ class ThumbnailFetcher internal constructor( } else { @Suppress("deprecation") var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null) - // from Android 10, returned thumbnail is already rotated according to EXIF orientation + // from Android 10 (API 29), returned thumbnail is already rotated according to EXIF orientation if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt index 258682b00..1f6590dd6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ActivityWindowHandler.kt @@ -12,7 +12,7 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti result.success(true) } - override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) { + private fun setWindowFlag(call: MethodCall, result: MethodChannel.Result, flag: Int) { val on = call.argument("on") if (on == null) { result.error("keepOn-args", "missing arguments", null) @@ -20,8 +20,6 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti } val window = activity.window - val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON - val old = (window.attributes.flags and flag) != 0 if (old != on) { if (on) { @@ -33,6 +31,14 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti result.success(null) } + override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) { + setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + override fun secureScreen(call: MethodCall, result: MethodChannel.Result) { + setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_SECURE) + } + override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) { val orientation = call.argument("orientation") if (orientation == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt index 46d1e43b8..3a50fd324 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/ServiceWindowHandler.kt @@ -13,6 +13,10 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) { result.success(null) } + override fun secureScreen(call: MethodCall, result: MethodChannel.Result) { + result.success(null) + } + override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) { result.success(false) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt index 0a6f41249..492c6deeb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/window/WindowHandler.kt @@ -13,6 +13,7 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho when (call.method) { "isActivity" -> Coresult.safe(call, result, ::isActivity) "keepScreenOn" -> Coresult.safe(call, result, ::keepScreenOn) + "secureScreen" -> Coresult.safe(call, result, ::secureScreen) "isRotationLocked" -> Coresult.safe(call, result, ::isRotationLocked) "requestOrientation" -> Coresult.safe(call, result, ::requestOrientation) "isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware) @@ -25,6 +26,8 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho abstract fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) + abstract fun secureScreen(call: MethodCall, result: MethodChannel.Result) + private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { var locked = false try { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 0b7ced26e..f618c0df1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -170,6 +170,11 @@ object XMP { } } // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device` + + // TODO TLAD [mp4] `IsoFile` init may fail if a skipped box has a `org.mp4parser.boxes.iso14496.part12.MetaBox` as parent, + // because `MetaBox.parse()` changes the argument `dataSource` to a `RewindableReadableByteChannel`, + // so it is no longer a seekable `FileChannel`, which is a requirement to skip boxes. + IsoFile(channel, boxParser).use { isoFile -> isoFile.processBoxes(UserBox::class.java, true) { box, _ -> val boxSize = box.size diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt index 9d5749561..e858c03e4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceEntry.kt @@ -33,6 +33,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory import java.io.IOException class SourceEntry { + private val origin: Int val uri: Uri // content or file URI var path: String? = null // best effort to get local path private val sourceMimeType: String @@ -48,12 +49,14 @@ class SourceEntry { private var foundExif: Boolean = false - constructor(uri: Uri, sourceMimeType: String) { + constructor(origin: Int, uri: Uri, sourceMimeType: String) { + this.origin = origin this.uri = uri this.sourceMimeType = sourceMimeType } constructor(map: FieldMap) { + origin = map["origin"] as Int uri = Uri.parse(map["uri"] as String) path = map["path"] as String? sourceMimeType = map["sourceMimeType"] as String @@ -77,6 +80,7 @@ class SourceEntry { fun toMap(): FieldMap { return hashMapOf( + "origin" to origin, "uri" to uri.toString(), "path" to path, "sourceMimeType" to sourceMimeType, @@ -249,13 +253,15 @@ class SourceEntry { private fun fillByTiffDecode(context: Context) { try { - val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return - val options = TiffBitmapFactory.Options().apply { - inJustDecodeBounds = true + context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> + val fd = pfd.detachFd() + val options = TiffBitmapFactory.Options().apply { + inJustDecodeBounds = true + } + TiffBitmapFactory.decodeFileDescriptor(fd, options) + width = options.outWidth + height = options.outHeight } - TiffBitmapFactory.decodeFileDescriptor(fd, options) - width = options.outWidth - height = options.outHeight } catch (e: Exception) { // ignore } @@ -267,5 +273,11 @@ class SourceEntry { is Int -> o.toLong() else -> o as? Long } + + // should match `EntryOrigins` on the Dart side + const val ORIGIN_MEDIA_STORE_CONTENT = 0 + const val ORIGIN_UNKNOWN_CONTENT = 1 + const val ORIGIN_FILE = 2 + const val ORIGIN_VAULT = 3 } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 7ff773d21..368035721 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -44,6 +44,7 @@ internal class ContentImageProvider : ImageProvider() { } val fields: FieldMap = hashMapOf( + "origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT, "uri" to uri.toString(), "sourceMimeType" to mimeType, ) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt index 3c3a624d5..6a1b0725e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/FileImageProvider.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.ContextWrapper import android.net.Uri import android.util.Log +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import java.io.File @@ -15,7 +16,7 @@ internal class FileImageProvider : ImageProvider() { return } - val entry = SourceEntry(uri, sourceMimeType) + val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType) val path = uri.path if (path != null) { @@ -52,6 +53,19 @@ internal class FileImageProvider : ImageProvider() { throw Exception("failed to delete entry with uri=$uri path=$path") } + override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) { + try { + val file = File(path) + if (file.exists()) { + newFields["dateModifiedSecs"] = file.lastModified() / 1000 + newFields["sizeBytes"] = file.length() + } + callback.onSuccess(newFields) + } catch (e: SecurityException) { + callback.onFailure(e) + } + } + companion object { private val LOG_TAG = LogUtils.createTag() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 440b45edf..5e568be50 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.os.Build import android.provider.MediaStore import android.util.Log +import androidx.core.net.toUri import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST @@ -220,6 +221,7 @@ class MediaStoreImageProvider : ImageProvider() { Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type") } else { var entryMap: FieldMap = hashMapOf( + "origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT, "uri" to itemUri.toString(), "path" to cursor.getString(pathColumn), "sourceMimeType" to mimeType, @@ -350,7 +352,7 @@ class MediaStoreImageProvider : ImageProvider() { } } catch (securityException: SecurityException) { // even if the app has access permission granted on the containing directory, - // the delete request may yield a `RecoverableSecurityException` on Android >=10 + // the delete request may yield a `RecoverableSecurityException` on API >=29 // when the underlying file no longer exists and this is an orphaned entry in the Media Store if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && contextWrapper is Activity) { Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException) @@ -387,10 +389,12 @@ class MediaStoreImageProvider : ImageProvider() { val entries = kv.value val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER + val toVault = StorageUtils.isInVault(activity, targetDir) + val toAppDir = toBin || toVault var effectiveTargetDir: String? = null var targetDirDocFile: DocumentFileCompat? = null - if (!toBin) { + if (!toAppDir) { effectiveTargetDir = targetDir targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) if (!File(targetDir).exists()) { @@ -438,13 +442,20 @@ class MediaStoreImageProvider : ImageProvider() { // - there is no documentation regarding support for usage with removable storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { - if (toBin) { - val trashDir = StorageUtils.trashDirFor(activity, sourcePath) - if (trashDir != null) { - effectiveTargetDir = ensureTrailingSeparator(trashDir.path) - targetDirDocFile = DocumentFileCompat.fromFile(trashDir) + val appDir = when { + toBin -> StorageUtils.trashDirFor(activity, sourcePath) + toVault -> File(targetDir) + else -> null + } + if (appDir != null) { + effectiveTargetDir = ensureTrailingSeparator(appDir.path) + targetDirDocFile = DocumentFileCompat.fromFile(appDir) + + if (toVault) { + appDir.mkdirs() } } + if (effectiveTargetDir != null) { val newFields = if (isCancelledOp()) skippedFieldMap else { val sourceFile = File(sourcePath) @@ -463,6 +474,7 @@ class MediaStoreImageProvider : ImageProvider() { mimeType = mimeType, copy = copy, toBin = toBin, + toVault = toVault, ) } } @@ -489,6 +501,7 @@ class MediaStoreImageProvider : ImageProvider() { mimeType: String, copy: Boolean, toBin: Boolean, + toVault: Boolean, ): FieldMap { val sourcePath = sourceFile.path val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } @@ -532,13 +545,21 @@ class MediaStoreImageProvider : ImageProvider() { Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) } } - if (toBin) { - return hashMapOf( + return if (toBin) { + hashMapOf( "trashed" to true, "trashPath" to targetPath, ) + } else if (toVault) { + hashMapOf( + "uri" to File(targetPath).toUri().toString(), + "contentId" to null, + "path" to targetPath, + "origin" to SourceEntry.ORIGIN_VAULT, + ) + } else { + scanNewPath(activity, targetPath, mimeType) } - return scanNewPath(activity, targetPath, mimeType) } // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry @@ -920,7 +941,7 @@ class MediaStoreImageProvider : ImageProvider() { private val VIDEO_PROJECTION = arrayOf( *BASE_PROJECTION, MediaColumns.DURATION, - // `ORIENTATION` was only available for images before Android 10 + // `ORIENTATION` was only available for images before Android 10 (API 29) *if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf( MediaStore.MediaColumns.ORIENTATION, ) else emptyArray() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index df4ea5f75..1215d7ab7 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -1,5 +1,6 @@ package deckers.thibault.aves.utils +import android.webkit.MimeTypeMap import androidx.exifinterface.media.ExifInterface object MimeTypes { @@ -153,47 +154,11 @@ object MimeTypes { // among other refs: // - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types fun extensionFor(mimeType: String): String? = when (mimeType) { - ARW -> ".arw" AVI, AVI_VND -> ".avi" - AVIF -> ".avif" - BMP -> ".bmp" - CR2 -> ".cr2" - CRW -> ".crw" - DCR -> ".dcr" - DJVU -> ".djvu" - DNG -> ".dng" - ERF -> ".erf" - GIF -> ".gif" HEIC, HEIF -> ".heif" - ICO -> ".ico" - JPEG -> ".jpg" - K25 -> ".k25" - KDC -> ".kdc" - MKV -> ".mkv" - MOV -> ".mov" MP2T, MP2TS -> ".m2ts" - MP4 -> ".mp4" - MRW -> ".mrw" - NEF -> ".nef" - NRW -> ".nrw" - OGV -> ".ogv" - ORF -> ".orf" - PEF -> ".pef" - PNG -> ".png" PSD_VND, PSD_X -> ".psd" - RAF -> ".raf" - RAW -> ".raw" - RW2 -> ".rw2" - SR2 -> ".sr2" - SRF -> ".srf" - SRW -> ".srw" - SVG -> ".svg" - TIFF -> ".tiff" - WBMP -> ".wbmp" - WEBM -> ".webm" - WEBP -> ".webp" - X3F -> ".x3f" - else -> null + else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" } } val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 08355a5de..2bad51da4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -119,7 +119,7 @@ object PermissionManager { dirSet.add("") } } else { - // request volume root until Android 10 + // request volume root until Android 10 (API 29) dirSet.add("") } dirsPerVolume[volumePath] = dirSet diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 962c28ce7..457a36e0e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -45,23 +45,23 @@ object StorageUtils { const val TRASH_PATH_PLACEHOLDER = "#trash" private fun isAppFile(context: Context, path: String): Boolean { - val filesDirs = context.getExternalFilesDirs(null).filterNotNull() - return filesDirs.any { path.startsWith(it.path) } + val dirs = context.getExternalFilesDirs(null).filterNotNull() + return dirs.any { path.startsWith(it.path) } } private fun appExternalFilesDirFor(context: Context, path: String): File? { - val filesDirs = context.getExternalFilesDirs(null).filterNotNull() + val dirs = context.getExternalFilesDirs(null).filterNotNull() val volumePath = getVolumePath(context, path) - return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.firstOrNull() + return volumePath?.let { dirs.firstOrNull { it.startsWith(volumePath) } } ?: dirs.firstOrNull() } fun trashDirFor(context: Context, path: String): File? { - val filesDir = appExternalFilesDirFor(context, path) - if (filesDir == null) { + val externalFilesDir = appExternalFilesDirFor(context, path) + if (externalFilesDir == null) { Log.e(LOG_TAG, "failed to find external files dir for path=$path") return null } - val trashDir = File(filesDir, "trash") + val trashDir = File(externalFilesDir, "trash") if (!trashDir.exists() && !trashDir.mkdirs()) { Log.e(LOG_TAG, "failed to create directories at path=$trashDir") return null @@ -69,6 +69,10 @@ object StorageUtils { return trashDir } + fun getVaultRoot(context: Context) = ensureTrailingSeparator(File(context.filesDir, "vault").path) + + fun isInVault(context: Context, path: String) = path.startsWith(getVaultRoot(context)) + /** * Volume paths */ @@ -545,7 +549,7 @@ object StorageUtils { } // As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used - // to work around a bug from Android 10 where metadata redaction corrupts HEIC images. + // to work around a bug from Android 10 (API 29) where metadata redaction corrupts HEIC images. // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` // for some non image/video content URIs (e.g. `downloads`, `file`) fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long? = null): Uri { diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..9f7329404 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/xml/full_backup_content.xml b/android/app/src/main/res/xml/full_backup_content.xml new file mode 100644 index 000000000..5e5ca09e7 --- /dev/null +++ b/android/app/src/main/res/xml/full_backup_content.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index e0e447a3d..3a528264c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/lib/app_mode.dart b/lib/app_mode.dart index de3cd6455..925c3d967 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -26,6 +26,11 @@ extension ExtraAppMode on AppMode { bool get canSelectFilter => this == AppMode.main; + bool get canCreateFilter => { + AppMode.main, + AppMode.pickFilterInternal, + }.contains(this); + bool get isPickingMedia => { AppMode.pickSingleMediaExternal, AppMode.pickMultipleMediaExternal, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cabefa0be..7104192c1 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -78,11 +78,14 @@ "chipActionFilterOut": "Filter out", "chipActionFilterIn": "Filter in", "chipActionHide": "Hide", + "chipActionLock": "Lock", "chipActionPin": "Pin to top", "chipActionUnpin": "Unpin from top", "chipActionRename": "Rename", "chipActionSetCover": "Set cover", "chipActionCreateAlbum": "Create album", + "chipActionCreateVault": "Create vault", + "chipActionConfigureVault": "Configure vault", "entryActionCopyToClipboard": "Copy to clipboard", "entryActionDelete": "Delete", @@ -158,6 +161,16 @@ "filterMimeImageLabel": "Image", "filterMimeVideoLabel": "Video", + "accessibilityAnimationsRemove": "Prevent screen effects", + "accessibilityAnimationsKeep": "Keep screen effects", + + "albumTierNew": "New", + "albumTierPinned": "Pinned", + "albumTierSpecial": "Common", + "albumTierApps": "Apps", + "albumTierVaults": "Vaults", + "albumTierRegular": "Others", + "coordinateFormatDms": "DMS", "coordinateFormatDecimal": "Decimal degrees", "coordinateDms": "{coordinate} {direction}", @@ -178,17 +191,13 @@ "coordinateDmsEast": "E", "coordinateDmsWest": "W", - "unitSystemMetric": "Metric", - "unitSystemImperial": "Imperial", + "displayRefreshRatePreferHighest": "Highest rate", + "displayRefreshRatePreferLowest": "Lowest rate", - "videoLoopModeNever": "Never", - "videoLoopModeShortOnly": "Short videos only", - "videoLoopModeAlways": "Always", - - "videoControlsPlay": "Play", - "videoControlsPlaySeek": "Play & seek backward/forward", - "videoControlsPlayOutside": "Open with other player", - "videoControlsNone": "None", + "keepScreenOnNever": "Never", + "keepScreenOnVideoPlayback": "During video playback", + "keepScreenOnViewerOnly": "Viewer page only", + "keepScreenOnAlways": "Always", "mapStyleGoogleNormal": "Google Maps", "mapStyleGoogleHybrid": "Google Maps (Hybrid)", @@ -203,28 +212,32 @@ "nameConflictStrategyReplace": "Replace", "nameConflictStrategySkip": "Skip", - "keepScreenOnNever": "Never", - "keepScreenOnVideoPlayback": "During video playback", - "keepScreenOnViewerOnly": "Viewer page only", - "keepScreenOnAlways": "Always", - - "accessibilityAnimationsRemove": "Prevent screen effects", - "accessibilityAnimationsKeep": "Keep screen effects", - - "displayRefreshRatePreferHighest": "Highest rate", - "displayRefreshRatePreferLowest": "Lowest rate", - "subtitlePositionTop": "Top", "subtitlePositionBottom": "Bottom", - "videoPlaybackSkip": "Skip", - "videoPlaybackMuted": "Play muted", - "videoPlaybackWithSound": "Play with sound", - "themeBrightnessLight": "Light", "themeBrightnessDark": "Dark", "themeBrightnessBlack": "Black", + "unitSystemMetric": "Metric", + "unitSystemImperial": "Imperial", + + "vaultLockTypePin": "Pin", + "vaultLockTypePassword": "Password", + + "videoControlsPlay": "Play", + "videoControlsPlaySeek": "Play & seek backward/forward", + "videoControlsPlayOutside": "Open with other player", + "videoControlsNone": "None", + + "videoLoopModeNever": "Never", + "videoLoopModeShortOnly": "Short videos only", + "videoLoopModeAlways": "Always", + + "videoPlaybackSkip": "Skip", + "videoPlaybackMuted": "Play muted", + "videoPlaybackWithSound": "Play with sound", + "viewerTransitionSlide": "Slide", "viewerTransitionParallax": "Parallax", "viewerTransitionFade": "Fade", @@ -242,12 +255,6 @@ "widgetOpenPageCollection": "Open collection", "widgetOpenPageViewer": "Open viewer", - "albumTierNew": "New", - "albumTierPinned": "Pinned", - "albumTierSpecial": "Common", - "albumTierApps": "Apps", - "albumTierRegular": "Others", - "storageVolumeDescriptionFallbackPrimary": "Internal storage", "storageVolumeDescriptionFallbackNonPrimary": "SD card", "rootDirectoryDescription": "root directory", @@ -367,6 +374,23 @@ "newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists", "newAlbumDialogStorageLabel": "Storage:", + "newVaultWarningDialogMessage": "Items in vaults are only available to this app and no others.\n\nIf you uninstall this app, or clear this app data, you will lose all these items.", + "newVaultDialogTitle": "New Vault", + "configureVaultDialogTitle": "Configure Vault", + "vaultDialogLockModeWhenScreenOff": "Lock when screen turns off", + "vaultDialogLockTypeLabel": "Lock type", + + "pinDialogEnter": "Enter pin", + "pinDialogConfirm": "Confirm pin", + + "passwordDialogEnter": "Enter password", + "passwordDialogConfirm": "Confirm password", + + "authenticateToConfigureVault": "Authenticate to configure vault", + "authenticateToUnlockVault": "Authenticate to unlock vault", + + "vaultBinUsageDialogMessage": "Some vaults are using the recycle bin.", + "renameAlbumDialogLabel": "New name", "renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists", @@ -635,7 +659,6 @@ "albumPageTitle": "Albums", "albumEmpty": "No albums", - "createAlbumTooltip": "Create album", "createAlbumButtonLabel": "CREATE", "newFilterBanner": "new", @@ -688,6 +711,7 @@ "settingsConfirmationBeforeMoveToBinItems": "Ask before moving items to the recycle bin", "settingsConfirmationBeforeMoveUndatedItems": "Ask before moving undated items", "settingsConfirmationAfterMoveToBinItems": "Show message after moving items to the recycle bin", + "settingsConfirmationVaultDataLoss": "Show vault data loss warning", "settingsNavigationDrawerTile": "Navigation menu", "settingsNavigationDrawerEditorPageTitle": "Navigation Menu", @@ -791,6 +815,7 @@ "settingsSaveSearchHistory": "Save search history", "settingsEnableBin": "Use recycle bin", "settingsEnableBinSubtitle": "Keep deleted items for 30 days", + "settingsDisablingBinWarningDialogMessage": "Items in the recycle bin will be deleted forever.", "settingsAllowMediaManagement": "Allow media management", "settingsHiddenItemsTile": "Hidden items", diff --git a/lib/model/actions/chip_actions.dart b/lib/model/actions/chip_actions.dart index 28b596579..bb7dc079a 100644 --- a/lib/model/actions/chip_actions.dart +++ b/lib/model/actions/chip_actions.dart @@ -8,6 +8,7 @@ enum ChipAction { goToTagPage, reverse, hide, + lockVault, } extension ExtraChipAction on ChipAction { @@ -24,6 +25,8 @@ extension ExtraChipAction on ChipAction { return context.l10n.chipActionFilterOut; case ChipAction.hide: return context.l10n.chipActionHide; + case ChipAction.lockVault: + return context.l10n.chipActionLock; } } @@ -41,6 +44,8 @@ extension ExtraChipAction on ChipAction { return AIcons.reverse; case ChipAction.hide: return AIcons.hide; + case ChipAction.lockVault: + return AIcons.vaultLock; } } } diff --git a/lib/model/actions/chip_set_actions.dart b/lib/model/actions/chip_set_actions.dart index a1e919801..7e2efa418 100644 --- a/lib/model/actions/chip_set_actions.dart +++ b/lib/model/actions/chip_set_actions.dart @@ -12,6 +12,7 @@ enum ChipSetAction { search, toggleTitleSearch, createAlbum, + createVault, // browsing or selecting map, slideshow, @@ -21,9 +22,11 @@ enum ChipSetAction { hide, pin, unpin, + lockVault, // selecting (single filter) rename, setCover, + configureVault, } class ChipSetActions { @@ -34,15 +37,20 @@ class ChipSetActions { ChipSetAction.selectNone, ]; + // `null` items are converted to dividers static const browsing = [ ChipSetAction.search, ChipSetAction.toggleTitleSearch, - ChipSetAction.createAlbum, + null, ChipSetAction.map, ChipSetAction.slideshow, ChipSetAction.stats, + null, + ChipSetAction.createAlbum, + ChipSetAction.createVault, ]; + // `null` items are converted to dividers static const selection = [ ChipSetAction.setCover, ChipSetAction.pin, @@ -50,9 +58,13 @@ class ChipSetActions { ChipSetAction.delete, ChipSetAction.rename, ChipSetAction.hide, + null, ChipSetAction.map, ChipSetAction.slideshow, ChipSetAction.stats, + null, + ChipSetAction.configureVault, + ChipSetAction.lockVault, ]; } @@ -76,6 +88,8 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.collectionActionShowTitleSearch; case ChipSetAction.createAlbum: return context.l10n.chipActionCreateAlbum; + case ChipSetAction.createVault: + return context.l10n.chipActionCreateVault; // browsing or selecting case ChipSetAction.map: return context.l10n.menuActionMap; @@ -92,11 +106,15 @@ extension ExtraChipSetAction on ChipSetAction { return context.l10n.chipActionPin; case ChipSetAction.unpin: return context.l10n.chipActionUnpin; + case ChipSetAction.lockVault: + return context.l10n.chipActionLock; // selecting (single filter) case ChipSetAction.rename: return context.l10n.chipActionRename; case ChipSetAction.setCover: return context.l10n.chipActionSetCover; + case ChipSetAction.configureVault: + return context.l10n.chipActionConfigureVault; } } @@ -121,6 +139,8 @@ extension ExtraChipSetAction on ChipSetAction { return AIcons.filter; case ChipSetAction.createAlbum: return AIcons.add; + case ChipSetAction.createVault: + return AIcons.vaultAdd; // browsing or selecting case ChipSetAction.map: return AIcons.map; @@ -137,11 +157,15 @@ extension ExtraChipSetAction on ChipSetAction { return AIcons.pin; case ChipSetAction.unpin: return AIcons.unpin; + case ChipSetAction.lockVault: + return AIcons.vaultLock; // selecting (single filter) case ChipSetAction.rename: return AIcons.name; case ChipSetAction.setCover: return AIcons.setCover; + case ChipSetAction.configureVault: + return AIcons.vaultConfigure; } } } diff --git a/lib/model/covers.dart b/lib/model/covers.dart index dfd07b8d3..601a07bd5 100644 --- a/lib/model/covers.dart +++ b/lib/model/covers.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; @@ -38,6 +39,8 @@ class Covers { Set get all => Set.unmodifiable(_rows); Tuple3? of(CollectionFilter filter) { + if (filter is AlbumFilter && vaults.isLocked(filter.album)) return null; + final row = _rows.firstWhereOrNull((row) => row.filter == filter); return row != null ? Tuple3(row.entryId, row.packageName, row.color) : null; } diff --git a/lib/model/db/db_metadata.dart b/lib/model/db/db_metadata.dart index cc23707bf..bf4f677ae 100644 --- a/lib/model/db/db_metadata.dart +++ b/lib/model/db/db_metadata.dart @@ -5,6 +5,7 @@ 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/vaults/details.dart'; import 'package:aves/model/video_playback.dart'; abstract class MetadataDb { @@ -16,17 +17,17 @@ abstract class MetadataDb { Future reset(); - Future removeIds(Iterable ids, {Set? dataTypes}); + Future removeIds(Set ids, {Set? dataTypes}); // entries Future clearEntries(); - Future> loadEntries({String? directory}); + Future> loadEntries({int? origin, String? directory}); - Future> loadEntriesById(Iterable ids); + Future> loadEntriesById(Set ids); - Future saveEntries(Iterable entries); + Future saveEntries(Set entries); Future updateEntry(int id, AvesEntry entry); @@ -44,7 +45,7 @@ abstract class MetadataDb { Future> loadCatalogMetadata(); - Future> loadCatalogMetadataById(Iterable ids); + Future> loadCatalogMetadataById(Set ids); Future saveCatalogMetadata(Set metadataEntries); @@ -56,12 +57,24 @@ abstract class MetadataDb { Future> loadAddresses(); - Future> loadAddressesById(Iterable ids); + Future> loadAddressesById(Set ids); Future saveAddresses(Set addresses); Future updateAddress(int id, AddressDetails? address); + // vaults + + Future clearVaults(); + + Future> loadAllVaults(); + + Future addVaults(Set rows); + + Future updateVault(String oldName, VaultDetails row); + + Future removeVaults(Set rows); + // trash Future clearTrashDetails(); @@ -76,11 +89,11 @@ abstract class MetadataDb { Future> loadAllFavourites(); - Future addFavourites(Iterable rows); + Future addFavourites(Set rows); Future updateFavouriteId(int id, FavouriteRow row); - Future removeFavourites(Iterable rows); + Future removeFavourites(Set rows); // covers @@ -88,7 +101,7 @@ abstract class MetadataDb { Future> loadAllCovers(); - Future addCovers(Iterable rows); + Future addCovers(Set rows); Future updateCoverEntryId(int id, CoverRow row); @@ -104,5 +117,5 @@ abstract class MetadataDb { Future addVideoPlayback(Set rows); - Future removeVideoPlayback(Iterable ids); + Future removeVideoPlayback(Set ids); } diff --git a/lib/model/db/db_metadata_sqflite.dart b/lib/model/db/db_metadata_sqflite.dart index 2e0e3e11d..9bc6a8885 100644 --- a/lib/model/db/db_metadata_sqflite.dart +++ b/lib/model/db/db_metadata_sqflite.dart @@ -9,6 +9,7 @@ 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/vaults/details.dart'; import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; @@ -26,6 +27,7 @@ class SqfliteMetadataDb implements MetadataDb { static const addressTable = 'address'; static const favouriteTable = 'favourites'; static const coverTable = 'covers'; + static const vaultTable = 'vaults'; static const trashTable = 'trash'; static const videoPlaybackTable = 'videoPlayback'; @@ -55,6 +57,7 @@ class SqfliteMetadataDb implements MetadataDb { ', sourceDateTakenMillis INTEGER' ', durationMillis INTEGER' ', trashed INTEGER DEFAULT 0' + ', origin INTEGER DEFAULT 0' ')'); await db.execute('CREATE TABLE $dateTakenTable(' 'id INTEGER PRIMARY KEY' @@ -89,6 +92,12 @@ class SqfliteMetadataDb implements MetadataDb { ', packageName TEXT' ', color INTEGER' ')'); + await db.execute('CREATE TABLE $vaultTable(' + 'name TEXT PRIMARY KEY' + ', autoLock INTEGER' + ', useBin INTEGER' + ', lockType TEXT' + ')'); await db.execute('CREATE TABLE $trashTable(' 'id INTEGER PRIMARY KEY' ', path TEXT' @@ -100,7 +109,7 @@ class SqfliteMetadataDb implements MetadataDb { ')'); }, onUpgrade: MetadataDbUpgrader.upgradeDb, - version: 10, + version: 11, ); final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable'); @@ -122,7 +131,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeIds(Iterable ids, {Set? dataTypes}) async { + Future removeIds(Set ids, {Set? dataTypes}) async { if (ids.isEmpty) return; final _dataTypes = dataTypes ?? EntryDataType.values.toSet(); @@ -162,15 +171,23 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future> loadEntries({String? directory}) async { + Future> loadEntries({int? origin, String? directory}) async { + String? where; + final whereArgs = []; + + if (origin != null) { + where = 'origin = ?'; + whereArgs.add(origin); + } + if (directory != null) { final separator = pContext.separator; if (!directory.endsWith(separator)) { directory = '$directory$separator'; } - const where = 'path LIKE ?'; - final whereArgs = ['$directory%']; + where = '${where != null ? '$where AND ' : ''}path LIKE ?'; + whereArgs.add('$directory%'); final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs); final dirLength = directory.length; @@ -184,15 +201,15 @@ class SqfliteMetadataDb implements MetadataDb { .toSet(); } - final rows = await _db.query(entryTable); + final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs); return rows.map(AvesEntry.fromMap).toSet(); } @override - Future> loadEntriesById(Iterable ids) => _getByIds(ids, entryTable, AvesEntry.fromMap); + Future> loadEntriesById(Set ids) => _getByIds(ids, entryTable, AvesEntry.fromMap); @override - Future saveEntries(Iterable entries) async { + Future saveEntries(Set entries) async { if (entries.isEmpty) return; final stopwatch = Stopwatch()..start(); final batch = _db.batch(); @@ -258,7 +275,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future> loadCatalogMetadataById(Iterable ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap); + Future> loadCatalogMetadataById(Set ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap); @override Future saveCatalogMetadata(Set metadataEntries) async { @@ -317,7 +334,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future> loadAddressesById(Iterable ids) => _getByIds(ids, addressTable, AddressDetails.fromMap); + Future> loadAddressesById(Set ids) => _getByIds(ids, addressTable, AddressDetails.fromMap); @override Future saveAddresses(Set addresses) async { @@ -346,6 +363,54 @@ class SqfliteMetadataDb implements MetadataDb { ); } + // vaults + + @override + Future clearVaults() async { + final count = await _db.delete(vaultTable, where: '1'); + debugPrint('$runtimeType clearVaults deleted $count rows'); + } + + @override + Future> loadAllVaults() async { + final rows = await _db.query(vaultTable); + return rows.map(VaultDetails.fromMap).toSet(); + } + + @override + Future addVaults(Set rows) async { + if (rows.isEmpty) return; + final batch = _db.batch(); + rows.forEach((row) => _batchInsertVault(batch, row)); + await batch.commit(noResult: true); + } + + @override + Future updateVault(String oldName, VaultDetails row) async { + final batch = _db.batch(); + batch.delete(vaultTable, where: 'name = ?', whereArgs: [oldName]); + _batchInsertVault(batch, row); + await batch.commit(noResult: true); + } + + void _batchInsertVault(Batch batch, VaultDetails row) { + batch.insert( + vaultTable, + row.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + @override + Future removeVaults(Set rows) async { + if (rows.isEmpty) return; + + // using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead + final batch = _db.batch(); + rows.map((v) => v.name).forEach((name) => batch.delete(vaultTable, where: 'name = ?', whereArgs: [name])); + await batch.commit(noResult: true); + } + // trash @override @@ -392,7 +457,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future addFavourites(Iterable rows) async { + Future addFavourites(Set rows) async { if (rows.isEmpty) return; final batch = _db.batch(); rows.forEach((row) => _batchInsertFavourite(batch, row)); @@ -416,7 +481,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeFavourites(Iterable rows) async { + Future removeFavourites(Set rows) async { if (rows.isEmpty) return; final ids = rows.map((row) => row.entryId); if (ids.isEmpty) return; @@ -442,7 +507,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future addCovers(Iterable rows) async { + Future addCovers(Set rows) async { if (rows.isEmpty) return; final batch = _db.batch(); @@ -532,7 +597,7 @@ class SqfliteMetadataDb implements MetadataDb { } @override - Future removeVideoPlayback(Iterable ids) async { + Future removeVideoPlayback(Set ids) async { if (ids.isEmpty) return; // using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead @@ -543,7 +608,7 @@ class SqfliteMetadataDb implements MetadataDb { // convenience methods - Future> _getByIds(Iterable ids, String table, T Function(Map row) mapRow) async { + Future> _getByIds(Set ids, String table, T Function(Map row) mapRow) async { if (ids.isEmpty) return {}; final rows = await _db.query( table, diff --git a/lib/model/db/db_metadata_sqflite_upgrade.dart b/lib/model/db/db_metadata_sqflite_upgrade.dart index 5d38540a8..f2840e2ee 100644 --- a/lib/model/db/db_metadata_sqflite_upgrade.dart +++ b/lib/model/db/db_metadata_sqflite_upgrade.dart @@ -10,6 +10,7 @@ class MetadataDbUpgrader { static const addressTable = SqfliteMetadataDb.addressTable; static const favouriteTable = SqfliteMetadataDb.favouriteTable; static const coverTable = SqfliteMetadataDb.coverTable; + static const vaultTable = SqfliteMetadataDb.vaultTable; static const trashTable = SqfliteMetadataDb.trashTable; static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable; @@ -45,6 +46,9 @@ class MetadataDbUpgrader { case 9: await _upgradeFrom9(db); break; + case 10: + await _upgradeFrom10(db); + break; } oldVersion++; } @@ -370,4 +374,17 @@ class MetadataDbUpgrader { }); await batch.commit(noResult: true); } + + static Future _upgradeFrom10(Database db) async { + debugPrint('upgrading DB from v10'); + + await db.execute('ALTER TABLE $entryTable ADD COLUMN origin INTEGER DEFAULT 0;'); + + await db.execute('CREATE TABLE $vaultTable(' + 'name TEXT PRIMARY KEY' + ', autoLock INTEGER' + ', useBin INTEGER' + ', lockType TEXT' + ')'); + } } diff --git a/lib/model/device.dart b/lib/model/device.dart index b69195128..e49802885 100644 --- a/lib/model/device.dart +++ b/lib/model/device.dart @@ -1,16 +1,20 @@ import 'package:aves/services/common/services.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:local_auth/local_auth.dart'; import 'package:package_info_plus/package_info_plus.dart'; final Device device = Device._private(); class Device { late final String _userAgent; - late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper; + late final bool _canAuthenticateUser, _canGrantDirectoryAccess, _canPinShortcut, _canPrint; + late final bool _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper, _canUseCrypto; late final bool _hasGeocoder, _isDynamicColorAvailable, _isTelevision, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode; String get userAgent => _userAgent; + bool get canAuthenticateUser => _canAuthenticateUser; + bool get canGrantDirectoryAccess => _canGrantDirectoryAccess; bool get canPinShortcut => _canPinShortcut; @@ -23,6 +27,10 @@ class Device { bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper; + bool get canUseCrypto => _canUseCrypto; + + bool get canUseVaults => canAuthenticateUser || canUseCrypto; + bool get hasGeocoder => _hasGeocoder; bool get isDynamicColorAvailable => _isDynamicColorAvailable; @@ -42,6 +50,9 @@ class Device { final androidInfo = await DeviceInfoPlugin().androidInfo; _isTelevision = androidInfo.systemFeatures.contains('android.software.leanback'); + final auth = LocalAuthentication(); + _canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported(); + final capabilities = await deviceService.getCapabilities(); _canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false; _canPinShortcut = capabilities['canPinShortcut'] ?? false; @@ -49,6 +60,7 @@ class Device { _canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false; _canRequestManageMedia = capabilities['canRequestManageMedia'] ?? false; _canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false; + _canUseCrypto = capabilities['canUseCrypto'] ?? false; _hasGeocoder = capabilities['hasGeocoder'] ?? false; _isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false; _showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false; diff --git a/lib/model/entry.dart b/lib/model/entry.dart index 6a9dc3625..794ed6f6e 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -20,6 +20,7 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/services/geocoding_service.dart'; import 'package:aves/services/metadata/svg_metadata_service.dart'; import 'package:aves/theme/format.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/time_utils.dart'; import 'package:collection/collection.dart'; @@ -29,6 +30,13 @@ import 'package:latlong2/latlong.dart'; enum EntryDataType { basic, aspectRatio, catalog, address, references } +class EntryOrigins { + static const int mediaStoreContent = 0; + static const int unknownContent = 1; + static const int file = 2; + static const int vault = 3; +} + class AvesEntry { // `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode int id; @@ -40,6 +48,7 @@ class AvesEntry { int width, height, sourceRotationDegrees; int? sizeBytes, dateAddedSecs, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis; bool trashed; + int origin; int? _catalogDateMillis; CatalogMetadata? _catalogMetadata; @@ -67,6 +76,7 @@ class AvesEntry { required this.sourceDateTakenMillis, required int? durationMillis, required this.trashed, + required this.origin, this.burstEntries, }) : id = id ?? 0 { this.path = path; @@ -87,6 +97,7 @@ class AvesEntry { String? title, int? dateAddedSecs, int? dateModifiedSecs, + int? origin, List? burstEntries, }) { final copyEntryId = id ?? this.id; @@ -107,6 +118,7 @@ class AvesEntry { sourceDateTakenMillis: sourceDateTakenMillis, durationMillis: durationMillis, trashed: trashed, + origin: origin ?? this.origin, burstEntries: burstEntries ?? this.burstEntries, ) ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) @@ -135,6 +147,7 @@ class AvesEntry { sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?, durationMillis: map['durationMillis'] as int?, trashed: (map['trashed'] as int? ?? 0) != 0, + origin: map['origin'] as int, ); } @@ -156,6 +169,7 @@ class AvesEntry { 'sourceDateTakenMillis': sourceDateTakenMillis, 'durationMillis': durationMillis, 'trashed': trashed ? 1 : 0, + 'origin': origin, }; } @@ -173,6 +187,7 @@ class AvesEntry { 'sizeBytes': sizeBytes, 'trashed': trashed, 'trashPath': trashDetails?.path, + 'origin': origin, }; } @@ -281,7 +296,9 @@ class AvesEntry { bool get isMediaStoreMediaContent => isMediaStoreContent && {'/external/images/', '/external/video/'}.any(uri.contains); - bool get canEdit => !settings.isReadOnly && path != null && !trashed && isMediaStoreContent; + bool get isVaultContent => path?.startsWith(androidFileUtils.vaultRoot) ?? false; + + bool get canEdit => !settings.isReadOnly && path != null && !trashed && (isMediaStoreContent || isVaultContent); bool get canEditDate => canEdit && (canEditExif || canEditXmp); diff --git a/lib/model/favourites.dart b/lib/model/favourites.dart index edfb2406e..d994e31cc 100644 --- a/lib/model/favourites.dart +++ b/lib/model/favourites.dart @@ -26,7 +26,7 @@ class Favourites with ChangeNotifier { FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(entryId: entry.id); Future add(Set entries) async { - final newRows = entries.map(_entryToRow); + final newRows = entries.map(_entryToRow).toSet(); await metadataDb.addFavourites(newRows); _rows.addAll(newRows); diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index e9b1722df..226436bbf 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -73,6 +73,7 @@ class AlbumFilter extends CoveredCollectionFilter { final albumType = covers.effectiveAlbumType(album); switch (albumType) { case AlbumType.regular: + case AlbumType.vault: break; case AlbumType.app: final appColor = colors.appColor(album); diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index e0d8159ce..b28945784 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -107,6 +107,7 @@ class MultiPageInfo { sourceDateTakenMillis: mainEntry.sourceDateTakenMillis, durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis, trashed: trashed, + origin: mainEntry.origin, ) ..catalogMetadata = mainEntry.catalogMetadata?.copyWith( mimeType: pageInfo.mimeType, diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index fb2273e6d..13d04e8fd 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -31,10 +31,7 @@ class SettingsDefaults { static const keepScreenOn = KeepScreenOn.viewerOnly; static const homePage = HomePageSetting.collection; static const enableBottomNavigationBar = true; - static const confirmDeleteForever = true; - static const confirmMoveToBin = true; - static const confirmMoveUndatedItems = true; - static const confirmAfterMoveToBin = true; + static const confirm = true; static const setMetadataDateBeforeFileOp = false; static final drawerTypeBookmarks = [ null, diff --git a/lib/model/settings/enums/enums.dart b/lib/model/settings/enums/enums.dart index 96f7fd39d..ac4584d5d 100644 --- a/lib/model/settings/enums/enums.dart +++ b/lib/model/settings/enums/enums.dart @@ -6,7 +6,7 @@ enum AvesThemeBrightness { system, light, dark, black } enum AvesThemeColorMode { monochrome, polychrome } -enum ConfirmationDialog { deleteForever, moveToBin, moveUndatedItems } +enum ConfirmationDialog { createVault, deleteForever, moveToBin, moveUndatedItems } enum CoordinateFormat { dms, decimal } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 50e55e931..ec3584ff1 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -41,7 +41,7 @@ class Settings extends ChangeNotifier { static const int _recentFilterHistoryMax = 10; static const Set _internalKeys = { hasAcceptedTermsKey, - catalogTimeZoneKey, + catalogTimeZoneRawOffsetMillisKey, searchHistoryKey, platformAccelerometerRotationKey, platformTransitionAnimationScaleKey, @@ -57,7 +57,7 @@ class Settings extends ChangeNotifier { static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed'; static const isErrorReportingAllowedKey = 'is_crashlytics_enabled'; static const localeKey = 'locale'; - static const catalogTimeZoneKey = 'catalog_time_zone'; + static const catalogTimeZoneRawOffsetMillisKey = 'catalog_time_zone_raw_offset_millis'; static const tileExtentPrefixKey = 'tile_extent_'; static const tileLayoutPrefixKey = 'tile_layout_'; static const entryRenamingPatternKey = 'entry_renaming_pattern'; @@ -78,6 +78,7 @@ class Settings extends ChangeNotifier { static const keepScreenOnKey = 'keep_screen_on'; static const homePageKey = 'home_page'; static const enableBottomNavigationBarKey = 'show_bottom_navigation_bar'; + static const confirmCreateVaultKey = 'confirm_create_vault'; static const confirmDeleteForeverKey = 'confirm_delete_forever'; static const confirmMoveToBinKey = 'confirm_move_to_bin'; static const confirmMoveUndatedItemsKey = 'confirm_move_undated_items'; @@ -282,10 +283,10 @@ class Settings extends ChangeNotifier { } Future sanitize() async { - if (timeToTakeAction == AccessibilityTimeout.system && !(await AccessibilityService.hasRecommendedTimeouts())) { + if (timeToTakeAction == AccessibilityTimeout.system && !await AccessibilityService.hasRecommendedTimeouts()) { _set(timeToTakeActionKey, null); } - if (viewerUseCutout != SettingsDefaults.viewerUseCutout && !(await windowService.isCutoutAware())) { + if (viewerUseCutout != SettingsDefaults.viewerUseCutout && !await windowService.isCutoutAware()) { _set(viewerUseCutoutKey, null); } } @@ -361,9 +362,9 @@ class Settings extends ChangeNotifier { return _appliedLocale!; } - String get catalogTimeZone => getString(catalogTimeZoneKey) ?? ''; + int get catalogTimeZoneRawOffsetMillis => getInt(catalogTimeZoneRawOffsetMillisKey) ?? 0; - set catalogTimeZone(String newValue) => _set(catalogTimeZoneKey, newValue); + set catalogTimeZoneRawOffsetMillis(int newValue) => _set(catalogTimeZoneRawOffsetMillisKey, newValue); double getTileExtent(String routeName) => getDouble(tileExtentPrefixKey + routeName) ?? 0; @@ -437,19 +438,23 @@ class Settings extends ChangeNotifier { set enableBottomNavigationBar(bool newValue) => _set(enableBottomNavigationBarKey, newValue); - bool get confirmDeleteForever => getBool(confirmDeleteForeverKey) ?? SettingsDefaults.confirmDeleteForever; + bool get confirmCreateVault => getBool(confirmCreateVaultKey) ?? SettingsDefaults.confirm; + + set confirmCreateVault(bool newValue) => _set(confirmCreateVaultKey, newValue); + + bool get confirmDeleteForever => getBool(confirmDeleteForeverKey) ?? SettingsDefaults.confirm; set confirmDeleteForever(bool newValue) => _set(confirmDeleteForeverKey, newValue); - bool get confirmMoveToBin => getBool(confirmMoveToBinKey) ?? SettingsDefaults.confirmMoveToBin; + bool get confirmMoveToBin => getBool(confirmMoveToBinKey) ?? SettingsDefaults.confirm; set confirmMoveToBin(bool newValue) => _set(confirmMoveToBinKey, newValue); - bool get confirmMoveUndatedItems => getBool(confirmMoveUndatedItemsKey) ?? SettingsDefaults.confirmMoveUndatedItems; + bool get confirmMoveUndatedItems => getBool(confirmMoveUndatedItemsKey) ?? SettingsDefaults.confirm; set confirmMoveUndatedItems(bool newValue) => _set(confirmMoveUndatedItemsKey, newValue); - bool get confirmAfterMoveToBin => getBool(confirmAfterMoveToBinKey) ?? SettingsDefaults.confirmAfterMoveToBin; + bool get confirmAfterMoveToBin => getBool(confirmAfterMoveToBinKey) ?? SettingsDefaults.confirm; set confirmAfterMoveToBin(bool newValue) => _set(confirmAfterMoveToBinKey, newValue); @@ -1019,6 +1024,7 @@ class Settings extends ChangeNotifier { case enableBlurEffectKey: case enableBottomNavigationBarKey: case mustBackTwiceToExitKey: + case confirmCreateVaultKey: case confirmDeleteForeverKey: case confirmMoveToBinKey: case confirmMoveUndatedItemsKey: diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index ba0d918ed..fae997994 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -2,6 +2,7 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/collection_utils.dart'; @@ -61,8 +62,10 @@ mixin AlbumMixin on SourceBase { } void updateDirectories() { - final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet(); - addDirectories(albums: visibleDirectories); + addDirectories(albums: { + ...visibleEntries.map((entry) => entry.directory), + ...vaults.all.map((v) => v.path), + }); cleanEmptyAlbums(); } @@ -73,22 +76,24 @@ mixin AlbumMixin on SourceBase { } } - void cleanEmptyAlbums([Set? albums]) { - final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet(); - if (emptyAlbums.isNotEmpty) { - _directories.removeAll(emptyAlbums); + void cleanEmptyAlbums([Set? albums]) { + final removableAlbums = (albums ?? _directories).where(_isRemovable).toSet(); + if (removableAlbums.isNotEmpty) { + _directories.removeAll(removableAlbums); _onAlbumChanged(); - invalidateAlbumFilterSummary(directories: emptyAlbums); + invalidateAlbumFilterSummary(directories: removableAlbums); final bookmarks = settings.drawerAlbumBookmarks; - emptyAlbums.forEach((album) { + removableAlbums.forEach((album) { bookmarks?.remove(album); }); settings.drawerAlbumBookmarks = bookmarks; } } - bool _isEmptyAlbum(String? album) => !visibleEntries.any((entry) => entry.directory == album); + bool _isRemovable(String album) { + return !(visibleEntries.any((entry) => entry.directory == album) || _newAlbums.contains(album) || vaults.isVault(album)); + } // filter summary @@ -166,8 +171,8 @@ mixin AlbumMixin on SourceBase { final separator = pContext.separator; assert(!dirPath.endsWith(separator)); + final type = androidFileUtils.getAlbumType(dirPath); if (context != null) { - final type = androidFileUtils.getAlbumType(dirPath); switch (type) { case AlbumType.camera: return context.l10n.albumCamera; @@ -180,11 +185,14 @@ mixin AlbumMixin on SourceBase { case AlbumType.videoCaptures: return context.l10n.albumVideoCaptures; case AlbumType.regular: + case AlbumType.vault: case AlbumType.app: break; } } + if (type == AlbumType.vault) return pContext.basename(dirPath); + final dir = VolumeRelativeDirectory.fromPath(dirPath); if (dir == null) return dirPath; diff --git a/lib/model/source/collection_source.dart b/lib/model/source/collection_source.dart index 41599a7f9..fcb163e1c 100644 --- a/lib/model/source/collection_source.dart +++ b/lib/model/source/collection_source.dart @@ -18,6 +18,7 @@ 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/model/vaults/vaults.dart'; import 'package:aves/services/analysis_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; @@ -60,9 +61,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM final oldValue = event.oldValue; if (oldValue is List?) { final oldHiddenFilters = (oldValue ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet(); - _onFilterVisibilityChanged(oldHiddenFilters, settings.hiddenFilters); + final newlyVisibleFilters = oldHiddenFilters.whereNot(settings.hiddenFilters.contains).toSet(); + _onFilterVisibilityChanged(newlyVisibleFilters); } }); + vaults.addListener(() { + final newlyVisibleFilters = vaults.vaultDirectories.whereNot(vaults.isLocked).map((v) => AlbumFilter(v, null)).toSet(); + _onFilterVisibilityChanged(newlyVisibleFilters); + }); } final EventBus _eventBus = EventBus(); @@ -108,16 +114,22 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM _savedDates = Map.unmodifiable(await metadataDb.loadDates()); } + Set _getAppHiddenFilters() => { + ...settings.hiddenFilters, + ...vaults.vaultDirectories.where(vaults.isLocked).map((v) => AlbumFilter(v, null)), + }; + Iterable _applyHiddenFilters(Iterable entries) { final hiddenFilters = { TrashFilter.instance, - ...settings.hiddenFilters, + ..._getAppHiddenFilters(), }; return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); } Iterable _applyTrashFilter(Iterable entries) { - return entries.where(TrashFilter.instance.test); + final hiddenFilters = _getAppHiddenFilters(); + return entries.where(TrashFilter.instance.test).where((entry) => !hiddenFilters.any((filter) => filter.test(entry))); } void _invalidate({Set? entries, bool notify = true}) { @@ -198,23 +210,24 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM Future _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async { newFields.keys.forEach((key) { + final newValue = newFields[key]; switch (key) { case 'contentId': - entry.contentId = newFields['contentId'] as int?; + entry.contentId = newValue 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?; + entry.dateModifiedSecs = newValue as int?; break; case 'path': - entry.path = newFields['path'] as String?; + entry.path = newValue as String?; break; case 'title': - entry.sourceTitle = newFields['title'] as String?; + entry.sourceTitle = newValue as String?; break; case 'trashed': - final trashed = newFields['trashed'] as bool; + final trashed = newValue as bool; entry.trashed = trashed; entry.trashDetails = trashed ? TrashDetails( @@ -225,7 +238,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM : null; break; case 'uri': - entry.uri = newFields['uri'] as String; + entry.uri = newValue as String; + break; + case 'origin': + entry.origin = newValue as int; break; } }); @@ -251,6 +267,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum); final pinned = settings.pinnedFilters.contains(oldFilter); + if (vaults.isVault(sourceAlbum)) { + await vaults.rename(sourceAlbum, destinationAlbum); + } + final existingCover = covers.of(oldFilter); await covers.set( filter: newFilter, @@ -266,6 +286,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM destinationAlbums: {destinationAlbum}, movedOps: movedOps, ); + // 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); @@ -312,6 +333,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM title: newFields['title'] as String?, dateAddedSecs: newFields['dateAddedSecs'] as int?, dateModifiedSecs: newFields['dateModifiedSecs'] as int?, + origin: newFields['origin'] as int?, )); } else { debugPrint('failed to find source entry with uri=$sourceUri'); @@ -345,7 +367,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM break; case MoveType.move: case MoveType.export: - cleanEmptyAlbums(fromAlbums); + cleanEmptyAlbums(fromAlbums.whereNotNull().toSet()); addDirectories(albums: destinationAlbums); break; case MoveType.toBin: @@ -507,11 +529,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM return recentEntry(filter); } - void _onFilterVisibilityChanged(Set oldHiddenFilters, Set currentHiddenFilters) { + void _onFilterVisibilityChanged(Set newlyVisibleFilters) { updateDerivedFilters(); eventBus.fire(const FilterVisibilityChangedEvent()); - 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); diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 3bf804a3a..67bfaa69d 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -8,6 +8,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:collection/collection.dart'; @@ -44,17 +45,18 @@ class MediaStoreSource extends CollectionSource { final stopwatch = Stopwatch()..start(); state = SourceState.loading; await metadataDb.init(); + await vaults.init(); await favourites.init(); await covers.init(); - final currentTimeZone = await deviceService.getDefaultTimeZone(); - if (currentTimeZone != null) { - final catalogTimeZone = settings.catalogTimeZone; - if (currentTimeZone != catalogTimeZone) { + final currentTimeZoneOffset = await deviceService.getDefaultTimeZoneRawOffsetMillis(); + if (currentTimeZoneOffset != null) { + final catalogTimeZoneOffset = settings.catalogTimeZoneRawOffsetMillis; + if (currentTimeZoneOffset != catalogTimeZoneOffset) { // 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.clearCatalogMetadata(); - settings.catalogTimeZone = currentTimeZone; + settings.catalogTimeZoneRawOffsetMillis = currentTimeZoneOffset; } } await loadDates(); @@ -74,7 +76,7 @@ class MediaStoreSource extends CollectionSource { final Set topEntries = {}; if (loadTopEntriesFirst) { - final topIds = settings.topEntryIds; + final topIds = settings.topEntryIds?.toSet(); if (topIds != null) { debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries'); topEntries.addAll(await metadataDb.loadEntriesById(topIds)); @@ -83,7 +85,7 @@ class MediaStoreSource extends CollectionSource { } debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries'); - final knownEntries = await metadataDb.loadEntries(directory: directory); + final knownEntries = await metadataDb.loadEntries(origin: EntryOrigins.mediaStoreContent, directory: directory); final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet(); debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries'); @@ -103,6 +105,8 @@ class MediaStoreSource extends CollectionSource { // with items that may be hidden right away because of their metadata addEntries(knownEntries, notify: false); + await _addVaultEntries(directory); + debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata'); if (directory != null) { final ids = knownLiveEntries.map((entry) => entry.id).toSet(); @@ -129,7 +133,7 @@ class MediaStoreSource extends CollectionSource { // clean up obsolete entries if (removedEntries.isNotEmpty) { debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries'); - await metadataDb.removeIds(removedEntries.map((entry) => entry.id)); + await metadataDb.removeIds(removedEntries.map((entry) => entry.id).toSet()); } // verify paths because some apps move files without updating their `last modified date` @@ -274,6 +278,36 @@ class MediaStoreSource extends CollectionSource { await refreshEntries(entriesToRefresh, EntryDataType.values.toSet()); } + await _refreshVaultEntries(changedUris.where(vaults.isVaultEntryUri).toSet()); + return tempUris; } + + // vault + + Future _addVaultEntries(String? directory) async { + addEntries(await metadataDb.loadEntries(origin: EntryOrigins.vault, directory: directory)); + } + + Future _refreshVaultEntries(Set changedUris) async { + final entriesToRefresh = {}; + final existingDirectories = {}; + + for (final uri in changedUris) { + final existingEntry = allEntries.firstWhereOrNull((entry) => entry.uri == uri); + if (existingEntry != null) { + entriesToRefresh.add(existingEntry); + final existingDirectory = existingEntry.directory; + if (existingDirectory != null) { + existingDirectories.add(existingDirectory); + } + } + } + + invalidateAlbumFilterSummary(directories: existingDirectories); + + if (entriesToRefresh.isNotEmpty) { + await refreshEntries(entriesToRefresh, EntryDataType.values.toSet()); + } + } } diff --git a/lib/model/vaults/details.dart b/lib/model/vaults/details.dart new file mode 100644 index 000000000..d4898148d --- /dev/null +++ b/lib/model/vaults/details.dart @@ -0,0 +1,57 @@ +import 'package:aves/model/vaults/enums.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/collection_utils.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +@immutable +class VaultDetails extends Equatable { + final String name; + final bool autoLockScreenOff, useBin; + final VaultLockType lockType; + + @override + List get props => [name, autoLockScreenOff, useBin, lockType]; + + const VaultDetails({ + required this.name, + required this.autoLockScreenOff, + required this.useBin, + required this.lockType, + }); + + VaultDetails copyWith({ + String? name, + }) { + return VaultDetails( + name: name ?? this.name, + autoLockScreenOff: autoLockScreenOff, + useBin: useBin, + lockType: lockType, + ); + } + + factory VaultDetails.fromMap(Map map) { + return VaultDetails( + name: map['name'] as String, + autoLockScreenOff: (map['autoLock'] as int? ?? 0) != 0, + useBin: (map['useBin'] as int? ?? 0) != 0, + lockType: VaultLockType.values.safeByName(map['lockType'] as String, VaultLockType.system), + ); + } + + Map toMap() => { + 'name': name, + 'autoLock': autoLockScreenOff ? 1 : 0, + 'useBin': useBin ? 1 : 0, + 'lockType': lockType.name, + }; + + String get passKey => 'vault_pass_$name'; + + String get path => '${androidFileUtils.vaultRoot}$name'; + + static String? nameFromPath(String path) { + return path.startsWith(androidFileUtils.vaultRoot) ? path.substring(androidFileUtils.vaultRoot.length) : null; + } +} diff --git a/lib/model/vaults/enums.dart b/lib/model/vaults/enums.dart new file mode 100644 index 000000000..339f4c9f7 --- /dev/null +++ b/lib/model/vaults/enums.dart @@ -0,0 +1,17 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/widgets.dart'; + +enum VaultLockType { system, pin, password } + +extension ExtraVaultLockType on VaultLockType { + String getText(BuildContext context) { + switch (this) { + case VaultLockType.system: + return context.l10n.settingsSystemDefault; + case VaultLockType.pin: + return context.l10n.vaultLockTypePin; + case VaultLockType.password: + return context.l10n.vaultLockTypePassword; + } + } +} diff --git a/lib/model/vaults/vaults.dart b/lib/model/vaults/vaults.dart new file mode 100644 index 000000000..64b914cc8 --- /dev/null +++ b/lib/model/vaults/vaults.dart @@ -0,0 +1,240 @@ +import 'dart:async'; + +import 'package:aves/model/vaults/details.dart'; +import 'package:aves/model/vaults/enums.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/password_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/pin_dialog.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:local_auth/error_codes.dart' as auth_error; +import 'package:local_auth/local_auth.dart'; +import 'package:screen_state/screen_state.dart'; + +final Vaults vaults = Vaults._private(); + +class Vaults extends ChangeNotifier { + final List _subscriptions = []; + Set _rows = {}; + final Set _unlockedDirPaths = {}; + + Vaults._private(); + + Future init() async { + _rows = await metadataDb.loadAllVaults(); + _vaultDirPaths = null; + final screenStateStream = Screen().screenStateStream; + if (screenStateStream != null) { + _subscriptions.add(screenStateStream.where((event) => event == ScreenStateEvent.SCREEN_OFF).listen((event) => _onScreenOff())); + } + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + super.dispose(); + } + + Set get all => Set.unmodifiable(_rows); + + VaultDetails? _detailsForPath(String dirPath) => _rows.firstWhereOrNull((v) => v.path == dirPath); + + Future create(VaultDetails details) async { + await metadataDb.addVaults({details}); + + _rows.add(details); + _vaultDirPaths = null; + _unlockedDirPaths.add(details.path); + _onLockStateChanged(); + } + + Future remove(Set dirPaths) async { + final details = dirPaths.map(_detailsForPath).whereNotNull().toSet(); + if (details.isEmpty) return; + + await metadataDb.removeVaults(details); + + await Future.forEach(details, (v) => securityService.writeValue(v.passKey, null)); + + _rows.removeAll(details); + _vaultDirPaths = null; + _unlockedDirPaths.removeAll(dirPaths); + _onLockStateChanged(); + } + + Future rename(String oldDirPath, String newDirPath) async { + final oldDetails = _detailsForPath(oldDirPath); + if (oldDetails == null) return; + + final newName = VaultDetails.nameFromPath(newDirPath); + if (newName == null) return; + + final newDetails = oldDetails.copyWith(name: newName); + await metadataDb.updateVault(oldDetails.name, newDetails); + + final pass = await securityService.readValue(oldDetails.passKey); + if (pass != null) { + await securityService.writeValue(newDetails.passKey, pass); + } + + _rows + ..remove(oldDetails) + ..add(newDetails); + _vaultDirPaths = null; + _unlockedDirPaths + ..remove(oldDirPath) + ..add(newDirPath); + _onLockStateChanged(); + } + + // update details, except name + Future update(VaultDetails newDetails) async { + final oldDetails = _detailsForPath(newDetails.path); + if (oldDetails == null) return; + + await metadataDb.updateVault(newDetails.name, newDetails); + + _rows + ..remove(oldDetails) + ..add(newDetails); + } + + Future clear() async { + await metadataDb.clearVaults(); + _rows.clear(); + _vaultDirPaths = null; + } + + Set? _vaultDirPaths; + + Set get vaultDirectories { + _vaultDirPaths ??= _rows.map((v) => v.path).toSet(); + return _vaultDirPaths!; + } + + VaultDetails? getVault(String? dirPath) => all.firstWhereOrNull((v) => v.path == dirPath); + + bool isVault(String dirPath) => vaultDirectories.contains(dirPath); + + bool isLocked(String dirPath) => isVault(dirPath) && !_unlockedDirPaths.contains(dirPath); + + bool isVaultEntryUri(String uriString) { + final uri = Uri.parse(uriString); + if (uri.scheme != 'file') return false; + + final path = uri.pathSegments.fold('', (prev, v) => '$prev${pContext.separator}$v'); + return vaultDirectories.any(path.startsWith); + } + + void lock(Set dirPaths) { + final unlocked = dirPaths.where((v) => isVault(v) && !isLocked(v)).toSet(); + if (unlocked.isEmpty) return; + + _unlockedDirPaths.removeAll(unlocked); + _onLockStateChanged(); + } + + Future tryUnlock(String dirPath, BuildContext context) async { + if (!isVault(dirPath) || !isLocked(dirPath)) return true; + + final details = _detailsForPath(dirPath); + if (details == null) return false; + + bool? confirmed; + switch (details.lockType) { + case VaultLockType.system: + try { + confirmed = await LocalAuthentication().authenticate( + localizedReason: context.l10n.authenticateToUnlockVault, + ); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + break; + case VaultLockType.pin: + final pin = await showDialog( + context: context, + builder: (context) => const PinDialog(needConfirmation: false), + routeSettings: const RouteSettings(name: PinDialog.routeName), + ); + if (pin != null) { + confirmed = pin == await securityService.readValue(details.passKey); + } + break; + case VaultLockType.password: + final password = await showDialog( + context: context, + builder: (context) => const PasswordDialog(needConfirmation: false), + routeSettings: const RouteSettings(name: PasswordDialog.routeName), + ); + if (password != null) { + confirmed = password == await securityService.readValue(details.passKey); + } + break; + } + + if (confirmed == null || !confirmed) return false; + + _unlockedDirPaths.add(dirPath); + _onLockStateChanged(); + return true; + } + + Future setPass(BuildContext context, VaultDetails details) async { + switch (details.lockType) { + case VaultLockType.system: + final l10n = context.l10n; + try { + return await LocalAuthentication().authenticate( + localizedReason: l10n.authenticateToConfigureVault, + ); + } on PlatformException catch (e, stack) { + await showDialog( + context: context, + builder: (context) => AvesDialog( + content: Text(e.message ?? l10n.genericFailureFeedback), + actions: const [OkButton()], + ), + routeSettings: const RouteSettings(name: AvesDialog.warningRouteName), + ); + if (e.code != auth_error.notAvailable) { + await reportService.recordError(e, stack); + } + } + break; + case VaultLockType.pin: + final pin = await showDialog( + context: context, + builder: (context) => const PinDialog(needConfirmation: true), + routeSettings: const RouteSettings(name: PinDialog.routeName), + ); + if (pin != null) { + return await securityService.writeValue(details.passKey, pin); + } + break; + case VaultLockType.password: + final password = await showDialog( + context: context, + builder: (context) => const PasswordDialog(needConfirmation: true), + routeSettings: const RouteSettings(name: PasswordDialog.routeName), + ); + if (password != null) { + return await securityService.writeValue(details.passKey, password); + } + break; + } + return false; + } + + void _onScreenOff() => lock(all.where((v) => v.autoLockScreenOff).map((v) => v.path).toSet()); + + void _onLockStateChanged() { + windowService.secureScreen(_unlockedDirPaths.isNotEmpty); + notifyListeners(); + } +} diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index 6962bd6c5..6fadb62e1 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -12,6 +12,7 @@ import 'package:aves/services/media/media_session_service.dart'; import 'package:aves/services/media/media_store_service.dart'; import 'package:aves/services/metadata/metadata_edit_service.dart'; import 'package:aves/services/metadata/metadata_fetch_service.dart'; +import 'package:aves/services/security_service.dart'; import 'package:aves/services/storage_service.dart'; import 'package:aves/services/window_service.dart'; import 'package:aves_report/aves_report.dart'; @@ -41,6 +42,7 @@ final MetadataEditService metadataEditService = getIt(); final MetadataFetchService metadataFetchService = getIt(); final MobileServices mobileServices = getIt(); final ReportService reportService = getIt(); +final SecurityService securityService = getIt(); final StorageService storageService = getIt(); final WindowService windowService = getIt(); @@ -60,6 +62,7 @@ void initPlatformServices() { getIt.registerLazySingleton(PlatformMetadataFetchService.new); getIt.registerLazySingleton(PlatformMobileServices.new); getIt.registerLazySingleton(PlatformReportService.new); + getIt.registerLazySingleton(PlatformSecurityService.new); getIt.registerLazySingleton(PlatformStorageService.new); getIt.registerLazySingleton(PlatformWindowService.new); } diff --git a/lib/services/device_service.dart b/lib/services/device_service.dart index b6d0612cb..03ea12764 100644 --- a/lib/services/device_service.dart +++ b/lib/services/device_service.dart @@ -8,7 +8,7 @@ abstract class DeviceService { Future> getCapabilities(); - Future getDefaultTimeZone(); + Future getDefaultTimeZoneRawOffsetMillis(); Future> getLocales(); @@ -45,9 +45,9 @@ class PlatformDeviceService implements DeviceService { } @override - Future getDefaultTimeZone() async { + Future getDefaultTimeZoneRawOffsetMillis() async { try { - return await _platform.invokeMethod('getDefaultTimeZone'); + return await _platform.invokeMethod('getDefaultTimeZoneRawOffsetMillis'); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } diff --git a/lib/services/security_service.dart b/lib/services/security_service.dart new file mode 100644 index 000000000..d6753c733 --- /dev/null +++ b/lib/services/security_service.dart @@ -0,0 +1,39 @@ +import 'package:aves/services/common/services.dart'; +import 'package:flutter/services.dart'; + +abstract class SecurityService { + Future writeValue(String key, T? value); + + Future readValue(String key); +} + +class PlatformSecurityService implements SecurityService { + static const _platform = MethodChannel('deckers.thibault/aves/security'); + + @override + Future writeValue(String key, T? value) async { + try { + await _platform.invokeMethod('writeValue', { + 'key': key, + 'value': value, + }); + return true; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + + @override + Future readValue(String key) async { + try { + final result = await _platform.invokeMethod('readValue', { + 'key': key, + }); + if (result != null) return result as T; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return null; + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a42f36e0a..2718acb32 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -9,6 +9,8 @@ import 'package:streams_channel/streams_channel.dart'; abstract class StorageService { Future> getStorageVolumes(); + Future getVaultRoot(); + Future getFreeSpace(StorageVolume volume); Future> getGrantedDirectories(); @@ -53,6 +55,17 @@ class PlatformStorageService implements StorageService { return {}; } + @override + Future getVaultRoot() async { + try { + final result = await _platform.invokeMethod('getVaultRoot'); + return result as String; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return ''; + } + @override Future getFreeSpace(StorageVolume volume) async { try { diff --git a/lib/services/window_service.dart b/lib/services/window_service.dart index 576b832b8..81796bff8 100644 --- a/lib/services/window_service.dart +++ b/lib/services/window_service.dart @@ -8,6 +8,8 @@ abstract class WindowService { Future keepScreenOn(bool on); + Future secureScreen(bool on); + Future isRotationLocked(); Future requestOrientation([Orientation? orientation]); @@ -42,6 +44,17 @@ class PlatformWindowService implements WindowService { } } + @override + Future secureScreen(bool on) async { + try { + await _platform.invokeMethod('secureScreen', { + 'on': on, + }); + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + } + @override Future isRotationLocked() async { try { diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index b0944abdc..a85aea75a 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -132,6 +132,9 @@ class AIcons { static const IconData streamVideo = Icons.movie_outlined; static const IconData streamAudio = Icons.audiotrack_outlined; static const IconData streamText = Icons.closed_caption_outlined; + static const IconData vaultLock = Icons.lock_outline; + static const IconData vaultAdd = Icons.enhanced_encryption_outlined; + static const IconData vaultConfigure = MdiIcons.shieldLockOutline; static const IconData videoSettings = Icons.video_settings_outlined; static const IconData view = Icons.grid_view_outlined; static const IconData zoomIn = Icons.add_outlined; @@ -147,6 +150,8 @@ class AIcons { static const IconData downloadAlbum = Icons.file_download; static const IconData screenshotAlbum = Icons.screenshot_outlined; static const IconData recordingAlbum = Icons.smartphone_outlined; + static const IconData locked = Icons.lock_outline; + static const IconData unlocked = Icons.lock_open_outlined; // thumbnail overlay static const IconData animated = Icons.slideshow; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index df8162f12..d49784fb6 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; @@ -10,7 +11,8 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { static const String trashDirPath = '#trash'; - late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; + late final String separator, vaultRoot, primaryStorage; + late final String dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath; late final Set videoCapturesPaths; Set storageVolumes = {}; Set _packages = {}; @@ -28,6 +30,7 @@ class AndroidFileUtils { separator = pContext.separator; await _initStorageVolumes(); + vaultRoot = await storageService.getVaultRoot(); primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator; // standard dcimPath = pContext.join(primaryStorage, 'DCIM'); @@ -90,15 +93,17 @@ class AndroidFileUtils { bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false; - AlbumType getAlbumType(String albumPath) { - if (isCameraPath(albumPath)) return AlbumType.camera; - if (isDownloadPath(albumPath)) return AlbumType.download; - if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings; - if (isScreenshotsPath(albumPath)) return AlbumType.screenshots; - if (isVideoCapturesPath(albumPath)) return AlbumType.videoCaptures; + AlbumType getAlbumType(String dirPath) { + if (vaults.isVault(dirPath)) return AlbumType.vault; - final dir = pContext.split(albumPath).last; - if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app; + if (isCameraPath(dirPath)) return AlbumType.camera; + if (isDownloadPath(dirPath)) return AlbumType.download; + if (isScreenRecordingsPath(dirPath)) return AlbumType.screenRecordings; + if (isScreenshotsPath(dirPath)) return AlbumType.screenshots; + if (isVideoCapturesPath(dirPath)) return AlbumType.videoCaptures; + + final dir = pContext.split(dirPath).last; + if (dirPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app; return AlbumType.regular; } @@ -115,7 +120,16 @@ class AndroidFileUtils { } } -enum AlbumType { regular, app, camera, download, screenRecordings, screenshots, videoCaptures } +enum AlbumType { + regular, + vault, + app, + camera, + download, + screenRecordings, + screenshots, + videoCaptures, +} class Package { final String packageName; diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart index 4983a8d62..d464e1429 100644 --- a/lib/utils/collection_utils.dart +++ b/lib/utils/collection_utils.dart @@ -17,3 +17,13 @@ extension ExtraMapNullableKeyValue on Map { extension ExtraNumIterable on Iterable { int get sum => fold(0, (prev, v) => prev + (v ?? 0)); } + +extension ExtraEnum on Iterable { + T safeByName(String name, T defaultValue) { + try { + return byName(name); + } catch (error) { + return defaultValue; + } + } +} diff --git a/lib/utils/dependencies.dart b/lib/utils/dependencies.dart index 27a0c94c9..4076d7df9 100644 --- a/lib/utils/dependencies.dart +++ b/lib/utils/dependencies.dart @@ -80,6 +80,12 @@ class Dependencies { license: mit, sourceUrl: 'https://github.com/ajinasokan/flutter_displaymode', ), + Dependency( + name: 'Local Auth', + license: bsd3, + licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/local_auth/local_auth/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/local_auth/local_auth', + ), Dependency( name: 'Package Info Plus', license: bsd3, @@ -101,11 +107,17 @@ class Dependencies { license: mit, sourceUrl: 'https://github.com/aaassseee/screen_brightness', ), + Dependency( + name: 'Screen State', + license: mit, + licenseUrl: 'https://github.com/cph-cachet/flutter-plugins/blob/master/packages/screen_state/LICENSE', + sourceUrl: 'https://github.com/cph-cachet/flutter-plugins/tree/master/packages/screen_state', + ), Dependency( name: 'Shared Preferences', license: bsd3, - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences', + licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/shared_preferences/shared_preferences/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/shared_preferences/shared_preferences', ), Dependency( name: 'sqflite', @@ -120,8 +132,8 @@ class Dependencies { Dependency( name: 'URL Launcher', license: bsd3, - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher', + licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/url_launcher/url_launcher/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/url_launcher/url_launcher', ), Dependency( name: 'Volume Controller', @@ -139,8 +151,8 @@ class Dependencies { Dependency( name: 'Google Maps for Flutter', license: bsd3, - licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE', - sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter', + licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/google_maps_flutter/google_maps_flutter/LICENSE', + sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/google_maps_flutter/google_maps_flutter', ), ]; @@ -219,8 +231,8 @@ class Dependencies { Dependency( name: 'Flutter Markdown', license: bsd3, - licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE', - sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown', + licenseUrl: 'https://github.com/flutter/packages/blob/main/packages/flutter_markdown/LICENSE', + sourceUrl: 'https://github.com/flutter/packages/tree/main/packages/flutter_markdown', ), Dependency( name: 'Flutter Staggered Animations', @@ -240,8 +252,8 @@ class Dependencies { Dependency( name: 'Palette Generator', license: bsd3, - licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE', - sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator', + licenseUrl: 'https://github.com/flutter/packages/blob/main/packages/palette_generator/LICENSE', + sourceUrl: 'https://github.com/flutter/packages/tree/main/packages/palette_generator', ), Dependency( name: 'Panorama (Aves fork)', @@ -253,6 +265,11 @@ class Dependencies { license: bsd2, sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator', ), + Dependency( + name: 'Pinput', + license: mit, + sourceUrl: 'https://github.com/Tkko/Flutter_PinPut', + ), Dependency( name: 'Provider', license: mit, @@ -294,8 +311,8 @@ class Dependencies { Dependency( name: 'Flutter Lints', license: bsd3, - licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_lints/LICENSE', - sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_lints', + licenseUrl: 'https://github.com/flutter/packages/blob/main/packages/flutter_lints/LICENSE', + sourceUrl: 'https://github.com/flutter/packages/tree/main/packages/flutter_lints', ), Dependency( name: 'Get It', diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index c115cf3a8..1fe1ad605 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -17,6 +17,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; @@ -24,6 +25,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:aves/utils/mime_utils.dart'; import 'package:aves/widgets/common/action_mixins/entry_editor.dart'; +import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; @@ -45,8 +47,6 @@ import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -import '../common/action_mixins/entry_storage.dart'; - class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, EntryEditorMixin, EntryStorageMixin { bool isVisible( EntrySetAction action, { @@ -284,10 +284,29 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware Future _delete(BuildContext context) async { final entries = _getTargetItems(context); + final byBinUsage = groupBy(entries, (entry) { + final details = vaults.getVault(entry.directory); + return details?.useBin ?? settings.enableBin; + }); + await Future.forEach( + byBinUsage.entries, + (kv) => doDelete( + context: context, + entries: kv.value.toSet(), + enableBin: kv.key, + )); + _browse(context); + } + + Future doDelete({ + required BuildContext context, + required Set entries, + required bool enableBin, + }) async { final pureTrash = entries.every((entry) => entry.trashed); - if (settings.enableBin && !pureTrash) { - await _move(context, moveType: MoveType.toBin); + if (enableBin && !pureTrash) { + await doMove(context, moveType: MoveType.toBin, entries: entries); return; } @@ -296,7 +315,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final storageDirs = entries.map((e) => e.storageDirectory).whereNotNull().toSet(); final todoCount = entries.length; - if (!await showConfirmationDialog( + if (!await showSkippableConfirmationDialog( context: context, type: ConfirmationDialog.deleteForever, message: l10n.deleteEntriesConfirmationDialogMessage(todoCount), @@ -329,8 +348,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware await storageService.deleteEmptyDirectories(storageDirs); }, ); - - _browse(context); } Future _move(BuildContext context, {required MoveType moveType}) async { diff --git a/lib/widgets/common/action_controls/quick_choosers/move_button.dart b/lib/widgets/common/action_controls/quick_choosers/move_button.dart index 9589524f8..c6ea6163d 100644 --- a/lib/widgets/common/action_controls/quick_choosers/move_button.dart +++ b/lib/widgets/common/action_controls/quick_choosers/move_button.dart @@ -38,11 +38,12 @@ class _MoveButtonState extends ChooserQuickButtonState { @override Widget buildChooser(Animation animation, PopupMenuPosition chooserPosition) { - final options = settings.recentDestinationAlbums; + final source = context.read(); + final rawAlbums = source.rawAlbums; + final options = settings.recentDestinationAlbums.where(rawAlbums.contains).toList(); final takeCount = MenuQuickChooser.maxOptionCount - options.length; if (takeCount > 0) { - final source = context.read(); - final filters = source.rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet(); + final filters = rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet(); final allMapEntries = filters.map((filter) => FilterGridItem(filter, source.recentEntry(filter))).toList(); allMapEntries.sort(FilterNavigationPage.compareFiltersByDate); options.addAll(allMapEntries.take(takeCount).map((v) => v.filter.album)); diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 0967d6e03..1d31c5add 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -194,7 +194,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { }) async { if (moveType == MoveType.toBin) { final l10n = context.l10n; - if (!await showConfirmationDialog( + if (!await showSkippableConfirmationDialog( context: context, type: ConfirmationDialog.moveToBin, message: l10n.binEntriesConfirmationDialogMessage(entries.length), @@ -291,7 +291,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { return dateMillis == null || dateMillis == 0; }).toSet(); if (undatedItems.isNotEmpty) { - if (!await showConfirmationDialog( + if (!await showSkippableConfirmationDialog( context: context, type: ConfirmationDialog.moveUndatedItems, delegate: MoveUndatedConfirmationDialogDelegate(), diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index 5949bb096..0fe88c8da 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -135,22 +135,26 @@ mixin FeedbackMixin { required Stream opStream, int? itemCount, VoidCallback? onCancel, - void Function(Set processed)? onDone, - }) => - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => ReportOverlay( - opStream: opStream, - itemCount: itemCount, - onCancel: onCancel, - onDone: (processed) { - Navigator.maybeOf(context)?.pop(); - onDone?.call(processed); - }, - ), - routeSettings: const RouteSettings(name: ReportOverlay.routeName), - ); + Future Function(Set processed)? onDone, + }) async { + final completer = Completer(); + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ReportOverlay( + opStream: opStream, + itemCount: itemCount, + onCancel: onCancel, + onDone: (processed) async { + Navigator.maybeOf(context)?.pop(); + await onDone?.call(processed); + completer.complete(); + }, + ), + routeSettings: const RouteSettings(name: ReportOverlay.routeName), + ); + return completer.future; + } } class ReportOverlay extends StatefulWidget { diff --git a/lib/widgets/common/action_mixins/vault_aware.dart b/lib/widgets/common/action_mixins/vault_aware.dart new file mode 100644 index 000000000..5c6e8e55c --- /dev/null +++ b/lib/widgets/common/action_mixins/vault_aware.dart @@ -0,0 +1,32 @@ +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/vaults/vaults.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; + +mixin VaultAwareMixin on FeedbackMixin { + Future unlockAlbum(BuildContext context, String dirPath) async { + final success = await vaults.tryUnlock(dirPath, context); + if (!success) { + showFeedback(context, context.l10n.genericFailureFeedback); + } + return success; + } + + Future unlockFilter(BuildContext context, CollectionFilter filter) { + return filter is AlbumFilter ? unlockAlbum(context, filter.album) : Future.value(true); + } + + Future unlockFilters(BuildContext context, Set filters) async { + var unlocked = true; + await Future.forEach(filters, (filter) async { + if (unlocked) { + unlocked = await unlockFilter(context, filter); + } + }); + return unlocked; + } + + void lockFilters(Set filters) => vaults.lock(filters.map((v) => v.album).toSet()); +} diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 6f61afd8b..e23294c20 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -99,6 +99,7 @@ class AvesFilterChip extends StatefulWidget { if (filter is TagFilter) ChipAction.goToTagPage, ChipAction.reverse, ChipAction.hide, + ChipAction.lockVault, ]; // remove focus, if any, to prevent the keyboard from showing up @@ -107,6 +108,7 @@ class AvesFilterChip extends StatefulWidget { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension); + final actionDelegate = ChipActionDelegate(); final selectedAction = await showMenu( context: context, position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size), @@ -115,7 +117,7 @@ class AvesFilterChip extends StatefulWidget { child: Text(filter.getLabel(context)), ), const PopupMenuDivider(), - ...actions.map((action) { + ...actions.where((action) => actionDelegate.isVisible(action, filter: filter)).map((action) { late String text; if (action == ChipAction.reverse) { text = filter.reversed ? context.l10n.chipActionFilterIn : context.l10n.chipActionFilterOut; @@ -134,7 +136,7 @@ class AvesFilterChip extends StatefulWidget { if (selectedAction != null) { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); - ChipActionDelegate().onActionSelected(context, filter, selectedAction); + actionDelegate.onActionSelected(context, filter, selectedAction); } } } diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 7bb9c9be3..20a3443d9 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -1,6 +1,7 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/covers.dart'; import 'package:aves/model/entry.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -339,8 +340,9 @@ class IconUtils { height: size, ) : null; + case AlbumType.vault: + return buildIcon(vaults.isLocked(albumPath) ? AIcons.locked : AIcons.unlocked); case AlbumType.regular: - default: return null; } } diff --git a/lib/widgets/debug/database.dart b/lib/widgets/debug/database.dart index 906277ceb..dca8795fa 100644 --- a/lib/widgets/debug/database.dart +++ b/lib/widgets/debug/database.dart @@ -4,10 +4,13 @@ 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/vaults/details.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/model/video_playback.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; class DebugAppDatabaseSection extends StatefulWidget { @@ -24,6 +27,7 @@ class _DebugAppDatabaseSectionState extends State with late Future> _dbMetadataLoader; late Future> _dbAddressLoader; late Future> _dbTrashLoader; + late Future> _dbVaultsLoader; late Future> _dbFavouritesLoader; late Future> _dbCoversLoader; late Future> _dbVideoPlaybackLoader; @@ -73,10 +77,12 @@ class _DebugAppDatabaseSectionState extends State with if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); + final entries = snapshot.data!; + final byOrigin = groupBy(entries, (entry) => entry.origin); return Row( children: [ Expanded( - child: Text('entry rows: ${snapshot.data!.length}'), + child: Text('entry rows: ${entries.length} (${byOrigin.entries.map((kv) => '${kv.key}: ${kv.value.length}').join(', ')})'), ), const SizedBox(width: 8), ElevatedButton( @@ -171,6 +177,27 @@ class _DebugAppDatabaseSectionState extends State with ); }, ), + FutureBuilder( + future: _dbVaultsLoader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + + if (snapshot.connectionState != ConnectionState.done) return const SizedBox(); + + return Row( + children: [ + Expanded( + child: Text('vault rows: ${snapshot.data!.length} (${vaults.all.length} in memory)'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: () => vaults.clear().then((_) => _startDbReport()), + child: const Text('Clear'), + ), + ], + ); + }, + ), FutureBuilder( future: _dbFavouritesLoader, builder: (context, snapshot) { @@ -248,6 +275,7 @@ class _DebugAppDatabaseSectionState extends State with _dbMetadataLoader = metadataDb.loadCatalogMetadata(); _dbAddressLoader = metadataDb.loadAddresses(); _dbTrashLoader = metadataDb.loadAllTrashDetails(); + _dbVaultsLoader = metadataDb.loadAllVaults(); _dbFavouritesLoader = metadataDb.loadAllFavourites(); _dbCoversLoader = metadataDb.loadAllCovers(); _dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback(); diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index d4fc6dc75..ee08be83f 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -47,6 +47,7 @@ class DebugSettingsSection extends StatelessWidget { padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), child: InfoRowGroup( info: { + 'catalogTimeZoneRawOffsetMillis': '${settings.catalogTimeZoneRawOffsetMillis}', 'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}', 'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}', 'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}', diff --git a/lib/widgets/dialogs/aves_confirmation_dialog.dart b/lib/widgets/dialogs/aves_confirmation_dialog.dart index f0cd3ff53..09cfa1c2d 100644 --- a/lib/widgets/dialogs/aves_confirmation_dialog.dart +++ b/lib/widgets/dialogs/aves_confirmation_dialog.dart @@ -5,6 +5,86 @@ import 'package:flutter/material.dart'; import 'aves_dialog.dart'; +Future showConfirmationDialog({ + required BuildContext context, + required String message, + required String confirmationButtonLabel, +}) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AvesDialog( + content: Text(message), + actions: [ + const CancelButton(), + TextButton( + onPressed: () => Navigator.maybeOf(context)?.pop(true), + child: Text(confirmationButtonLabel), + ), + ], + ), + routeSettings: const RouteSettings(name: AvesDialog.confirmationRouteName), + ); + return confirmed ?? false; +} + +Future showSkippableConfirmationDialog({ + required BuildContext context, + required ConfirmationDialog type, + String? message, + ConfirmationDialogDelegate? delegate, + required String confirmationButtonLabel, +}) async { + if (!_shouldConfirm(type)) return true; + + assert((message != null) ^ (delegate != null)); + final effectiveDelegate = delegate ?? MessageConfirmationDialogDelegate(message!); + final confirmed = await showDialog( + context: context, + builder: (context) => _SkippableConfirmationDialog( + type: type, + delegate: effectiveDelegate, + confirmationButtonLabel: confirmationButtonLabel, + ), + routeSettings: const RouteSettings(name: _SkippableConfirmationDialog.routeName), + ); + if (confirmed == null) return false; + + if (confirmed) { + effectiveDelegate.apply(); + } + return confirmed; +} + +bool _shouldConfirm(ConfirmationDialog type) { + switch (type) { + case ConfirmationDialog.createVault: + return settings.confirmCreateVault; + case ConfirmationDialog.deleteForever: + return settings.confirmDeleteForever; + case ConfirmationDialog.moveToBin: + return settings.confirmMoveToBin; + case ConfirmationDialog.moveUndatedItems: + return settings.confirmMoveUndatedItems; + } +} + +void _skipConfirmation(ConfirmationDialog type) { + switch (type) { + case ConfirmationDialog.createVault: + settings.confirmCreateVault = false; + break; + case ConfirmationDialog.deleteForever: + settings.confirmDeleteForever = false; + break; + case ConfirmationDialog.moveToBin: + settings.confirmMoveToBin = false; + break; + case ConfirmationDialog.moveUndatedItems: + settings.confirmMoveUndatedItems = false; + break; + } +} + abstract class ConfirmationDialogDelegate { List build(BuildContext context); @@ -25,77 +105,24 @@ class MessageConfirmationDialogDelegate extends ConfirmationDialogDelegate { ]; } -Future showConfirmationDialog({ - required BuildContext context, - required ConfirmationDialog type, - String? message, - ConfirmationDialogDelegate? delegate, - required String confirmationButtonLabel, -}) async { - if (!_shouldConfirm(type)) return true; - - assert((message != null) ^ (delegate != null)); - final effectiveDelegate = delegate ?? MessageConfirmationDialogDelegate(message!); - final confirmed = await showDialog( - context: context, - builder: (context) => _AvesConfirmationDialog( - type: type, - delegate: effectiveDelegate, - confirmationButtonLabel: confirmationButtonLabel, - ), - routeSettings: const RouteSettings(name: _AvesConfirmationDialog.routeName), - ); - if (confirmed == null) return false; - - if (confirmed) { - effectiveDelegate.apply(); - } - return confirmed; -} - -bool _shouldConfirm(ConfirmationDialog type) { - switch (type) { - case ConfirmationDialog.deleteForever: - return settings.confirmDeleteForever; - case ConfirmationDialog.moveToBin: - return settings.confirmMoveToBin; - case ConfirmationDialog.moveUndatedItems: - return settings.confirmMoveUndatedItems; - } -} - -void _skipConfirmation(ConfirmationDialog type) { - switch (type) { - case ConfirmationDialog.deleteForever: - settings.confirmDeleteForever = false; - break; - case ConfirmationDialog.moveToBin: - settings.confirmMoveToBin = false; - break; - case ConfirmationDialog.moveUndatedItems: - settings.confirmMoveUndatedItems = false; - break; - } -} - -class _AvesConfirmationDialog extends StatefulWidget { - static const routeName = '/dialog/confirmation'; +class _SkippableConfirmationDialog extends StatefulWidget { + static const routeName = '/dialog/skippable_confirmation'; final ConfirmationDialog type; final ConfirmationDialogDelegate delegate; final String confirmationButtonLabel; - const _AvesConfirmationDialog({ + const _SkippableConfirmationDialog({ required this.type, required this.delegate, required this.confirmationButtonLabel, }); @override - State<_AvesConfirmationDialog> createState() => _AvesConfirmationDialogState(); + State<_SkippableConfirmationDialog> createState() => _SkippableConfirmationDialogState(); } -class _AvesConfirmationDialogState extends State<_AvesConfirmationDialog> { +class _SkippableConfirmationDialogState extends State<_SkippableConfirmationDialog> { final ValueNotifier _skip = ValueNotifier(false); @override diff --git a/lib/widgets/dialogs/aves_dialog.dart b/lib/widgets/dialogs/aves_dialog.dart index e67f3176a..c6c135bdf 100644 --- a/lib/widgets/dialogs/aves_dialog.dart +++ b/lib/widgets/dialogs/aves_dialog.dart @@ -29,7 +29,7 @@ class AvesDialog extends StatelessWidget { this.scrollableContent, this.horizontalContentPadding = defaultHorizontalContentPadding, this.content, - required this.actions, + this.actions = const [], }) : assert((scrollableContent != null) ^ (content != null)), scrollController = scrollController ?? ScrollController(); diff --git a/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart index baba84206..a2636158d 100644 --- a/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart @@ -9,12 +9,11 @@ import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../aves_dialog.dart'; - class RemoveEntryMetadataDialog extends StatefulWidget { static const routeName = '/dialog/remove_entry_metadata'; diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart index 36f4fb0d8..262b60474 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_dialog.dart @@ -4,10 +4,9 @@ import 'package:aves/model/entry.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:flutter/material.dart'; -import '../aves_dialog.dart'; - class RenameEntryDialog extends StatefulWidget { static const routeName = '/dialog/rename_entry'; diff --git a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart index 579c5de88..def2ecef2 100644 --- a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -4,11 +4,10 @@ import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../aves_dialog.dart'; - class CreateAlbumDialog extends StatefulWidget { static const routeName = '/dialog/create_album'; diff --git a/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart b/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart new file mode 100644 index 000000000..52c81df08 --- /dev/null +++ b/lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart @@ -0,0 +1,184 @@ +import 'package:aves/model/device.dart'; +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/vaults/details.dart'; +import 'package:aves/model/vaults/enums.dart'; +import 'package:aves/model/vaults/vaults.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_caption.dart'; +import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class EditVaultDialog extends StatefulWidget { + static const routeName = '/dialog/edit_vault'; + + final VaultDetails? initialDetails; + + const EditVaultDialog({ + super.key, + this.initialDetails, + }); + + @override + State createState() => _EditVaultDialogState(); +} + +class _EditVaultDialogState extends State { + final TextEditingController _nameController = TextEditingController(); + late bool _useBin; + late bool _autoLockScreenOff; + late VaultLockType _lockType; + + final ValueNotifier _existsNotifier = ValueNotifier(false); + final ValueNotifier _isValidNotifier = ValueNotifier(false); + + final List _lockTypeOptions = [ + if (device.canAuthenticateUser) VaultLockType.system, + if (device.canUseCrypto) ...[ + VaultLockType.pin, + VaultLockType.password, + ], + ]; + + VaultDetails? get initialDetails => widget.initialDetails; + + String get newName => _nameController.text; + + @override + void initState() { + super.initState(); + final details = initialDetails ?? + VaultDetails( + name: '', + autoLockScreenOff: true, + useBin: settings.enableBin, + lockType: _lockTypeOptions.first, + ); + _nameController.text = details.name; + _useBin = details.useBin; + _autoLockScreenOff = details.autoLockScreenOff; + _lockType = details.lockType; + _validate(); + } + + @override + void dispose() { + _nameController.dispose(); + _existsNotifier.dispose(); + _isValidNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final isNew = initialDetails == null; + return AvesDialog( + title: isNew ? l10n.newVaultDialogTitle : l10n.configureVaultDialogTitle, + scrollableContent: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ValueListenableBuilder( + valueListenable: _existsNotifier, + builder: (context, exists, child) { + return TextField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.newAlbumDialogNameLabel, + helperText: exists ? l10n.newAlbumDialogNameLabelAlreadyExistsHelper : '', + ), + onChanged: (_) => _validate(), + onSubmitted: (_) => _submit(context), + ); + }), + ), + if (_lockTypeOptions.length > 1) + ListTile( + title: Text(l10n.vaultDialogLockTypeLabel), + subtitle: AvesCaption(_lockType.getText(context)), + onTap: () { + _unfocus(); + showSelectionDialog( + context: context, + builder: (context) => AvesSelectionDialog( + initialValue: _lockType, + options: Map.fromEntries(_lockTypeOptions.map((v) => MapEntry(v, v.getText(context)))), + ), + onSelection: (v) => setState(() => _lockType = v), + ); + }, + ), + SwitchListTile( + value: _autoLockScreenOff, + onChanged: (v) => setState(() => _autoLockScreenOff = v), + title: Text(l10n.vaultDialogLockModeWhenScreenOff), + ), + if (settings.enableBin) + SwitchListTile( + value: _useBin, + onChanged: (v) async { + if (!v) { + final album = initialDetails?.path; + if (album != null) { + final filter = AlbumFilter(album, null); + final source = context.read(); + if (source.trashedEntries.any(filter.test)) { + if (!await showConfirmationDialog( + context: context, + message: l10n.settingsDisablingBinWarningDialogMessage, + confirmationButtonLabel: l10n.applyButtonLabel, + )) return; + } + } + } + setState(() => _useBin = v); + }, + title: Text(l10n.settingsEnableBin), + ), + ], + actions: [ + const CancelButton(), + ValueListenableBuilder( + valueListenable: _isValidNotifier, + builder: (context, isValid, child) { + return TextButton( + onPressed: isValid ? () => _submit(context) : null, + child: Text(isNew ? l10n.createAlbumButtonLabel : l10n.applyButtonLabel), + ); + }, + ), + ], + ); + } + + // remove focus, if any, to prevent the keyboard from showing up + // after the user is done with the dialog + void _unfocus() => FocusManager.instance.primaryFocus?.unfocus(); + + Future _validate() async { + final notEmpty = newName.isNotEmpty; + final exists = notEmpty && vaults.all.map((v) => v.name).contains(newName) && newName != initialDetails?.name; + _existsNotifier.value = exists; + _isValidNotifier.value = notEmpty && !exists; + } + + Future _submit(BuildContext context) async { + if (!_isValidNotifier.value) return; + + _unfocus(); + + final details = VaultDetails( + name: newName, + autoLockScreenOff: _autoLockScreenOff, + useBin: _useBin, + lockType: _lockType, + ); + if (!await vaults.setPass(context, details)) return; + + Navigator.maybeOf(context)?.pop(details); + } +} diff --git a/lib/widgets/dialogs/filter_editors/password_dialog.dart b/lib/widgets/dialogs/filter_editors/password_dialog.dart new file mode 100644 index 000000000..a29cf315a --- /dev/null +++ b/lib/widgets/dialogs/filter_editors/password_dialog.dart @@ -0,0 +1,64 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; + +class PasswordDialog extends StatefulWidget { + static const routeName = '/dialog/password'; + + final bool needConfirmation; + + const PasswordDialog({ + super.key, + required this.needConfirmation, + }); + + @override + State createState() => _PasswordDialogState(); +} + +class _PasswordDialogState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + bool _confirming = false; + String? _firstPassword; + + @override + void initState() { + super.initState(); + _focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_confirming ? context.l10n.passwordDialogConfirm : context.l10n.passwordDialogEnter), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: TextField( + controller: _controller, + focusNode: _focusNode, + obscureText: true, + onSubmitted: (password) { + if (widget.needConfirmation) { + if (_confirming) { + Navigator.maybeOf(context)?.pop(_firstPassword == password ? password : null); + } else { + _firstPassword = password; + _controller.clear(); + setState(() => _confirming = true); + WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); + } + } else { + Navigator.maybeOf(context)?.pop(password); + } + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/dialogs/filter_editors/pin_dialog.dart b/lib/widgets/dialogs/filter_editors/pin_dialog.dart new file mode 100644 index 000000000..6afd18420 --- /dev/null +++ b/lib/widgets/dialogs/filter_editors/pin_dialog.dart @@ -0,0 +1,65 @@ +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:pinput/pinput.dart'; + +class PinDialog extends StatefulWidget { + static const routeName = '/dialog/pin'; + + final bool needConfirmation; + + const PinDialog({ + super.key, + required this.needConfirmation, + }); + + @override + State createState() => _PinDialogState(); +} + +class _PinDialogState extends State { + final _controller = TextEditingController(); + final _focusNode = FocusNode(); + bool _confirming = false; + String? _firstPin; + + @override + void initState() { + super.initState(); + _focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + return AvesDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_confirming ? context.l10n.pinDialogConfirm : context.l10n.pinDialogEnter), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Pinput( + onCompleted: (pin) { + if (widget.needConfirmation) { + if (_confirming) { + Navigator.maybeOf(context)?.pop(_firstPin == pin ? pin : null); + } else { + _firstPin = pin; + _controller.clear(); + setState(() => _confirming = true); + WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); + } + } else { + Navigator.maybeOf(context)?.pop(pin); + } + }, + controller: _controller, + focusNode: _focusNode, + obscureText: true, + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart index d2cabd10b..a391265e5 100644 --- a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart +++ b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart @@ -4,10 +4,13 @@ import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/model/vaults/details.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/basic/menu.dart'; @@ -16,7 +19,9 @@ import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; +import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/edit_vault_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart'; import 'package:aves/widgets/filter_grids/common/app_bar.dart'; @@ -79,6 +84,19 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { } } + static const _quickActions = [ + ChipSetAction.createAlbum, + ]; + + // `null` items are converted to dividers + static const _menuActions = [ + ...ChipSetActions.general, + null, + ChipSetAction.toggleTitleSearch, + null, + ChipSetAction.createVault, + ]; + @override Widget build(BuildContext context) { return ListenableProvider>.value( @@ -141,23 +159,37 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { selectedFilters: selectedFilters, ); + void onActionSelected(ChipSetAction action) { + switch (action) { + case ChipSetAction.createAlbum: + _createAlbum(); + break; + case ChipSetAction.createVault: + _createVault(); + break; + default: + actionDelegate.onActionSelected(context, {}, action); + break; + } + } + return settings.useTvLayout ? _buildTelevisionActions( context: context, isVisible: isVisible, - actionDelegate: actionDelegate, + onActionSelected: onActionSelected, ) : _buildMobileActions( context: context, isVisible: isVisible, - actionDelegate: actionDelegate, + onActionSelected: onActionSelected, ); } List _buildTelevisionActions({ required BuildContext context, required bool Function(ChipSetAction action) isVisible, - required AlbumChipSetActionDelegate actionDelegate, + required void Function(ChipSetAction action) onActionSelected, }) { return [ ...ChipSetActions.general, @@ -165,7 +197,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { return CaptionedButton( icon: action.getIcon(), caption: action.getText(context), - onPressed: () => actionDelegate.onActionSelected(context, {}, action), + onPressed: () => onActionSelected(action), ); }).toList(); } @@ -173,34 +205,22 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { List _buildMobileActions({ required BuildContext context, required bool Function(ChipSetAction action) isVisible, - required AlbumChipSetActionDelegate actionDelegate, + required void Function(ChipSetAction action) onActionSelected, }) { return [ if (widget.moveType != null) - IconButton( - icon: const Icon(AIcons.add), - onPressed: () async { - final newAlbum = await showDialog( - context: context, - builder: (context) => const CreateAlbumDialog(), - routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), - ); - // wait for the dialog to hide as applying the change may block the UI - await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); - if (newAlbum != null && newAlbum.isNotEmpty) { - Navigator.maybeOf(context)?.pop(AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum))); - } - }, - tooltip: context.l10n.createAlbumTooltip, - ), + ..._quickActions.where(isVisible).map((action) => IconButton( + icon: action.getIcon(), + onPressed: () => onActionSelected(action), + tooltip: action.getText(context), + )), MenuIconTheme( child: PopupMenuButton( itemBuilder: (context) { - return [ - ...ChipSetActions.general.where(isVisible).map((action) => FilterGridAppBar.toMenuItem(context, action, enabled: true)), - const PopupMenuDivider(), - FilterGridAppBar.toMenuItem(context, ChipSetAction.toggleTitleSearch, enabled: true), - ]; + return _menuActions.where((v) => v == null || isVisible(v)).map((action) { + if (action == null) return const PopupMenuDivider(); + return FilterGridAppBar.toMenuItem(context, action, enabled: true); + }).toList(); }, onSelected: (action) async { // remove focus, if any, to prevent the keyboard from showing up @@ -209,10 +229,53 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { // wait for the popup menu to hide before proceeding with the action await Future.delayed(Durations.popupMenuAnimation * timeDilation); - actionDelegate.onActionSelected(context, {}, action); + onActionSelected(action); }, ), ), ]; } + + Future _createAlbum() async { + final directory = await showDialog( + context: context, + builder: (context) => const CreateAlbumDialog(), + routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), + ); + if (directory == null) return; + + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + + _pickAlbum(directory); + } + + Future _createVault() async { + final l10n = context.l10n; + if (!await showSkippableConfirmationDialog( + context: context, + type: ConfirmationDialog.createVault, + message: l10n.newVaultWarningDialogMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + )) return; + + final details = await showDialog( + context: context, + builder: (context) => const EditVaultDialog(), + routeSettings: const RouteSettings(name: EditVaultDialog.routeName), + ); + if (details == null) return; + + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.dialogTransitionAnimation * timeDilation); + + await vaults.create(details); + _pickAlbum(details.path); + } + + void _pickAlbum(String directory) { + source.createAlbum(directory); + final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory)); + Navigator.maybeOf(context)?.pop(filter); + } } diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index eade6bd8f..b39500edd 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -99,6 +99,7 @@ class AlbumListPage extends StatelessWidget { case AlbumChipGroupFactor.importance: final specialKey = AlbumImportanceSectionKey.special(context); final appsKey = AlbumImportanceSectionKey.apps(context); + final vaultKey = AlbumImportanceSectionKey.vault(context); final regularKey = AlbumImportanceSectionKey.regular(context); sections = groupBy, ChipSectionKey>(unpinnedMapEntries, (kv) { switch (covers.effectiveAlbumType(kv.filter.album)) { @@ -106,6 +107,8 @@ class AlbumListPage extends StatelessWidget { return regularKey; case AlbumType.app: return appsKey; + case AlbumType.vault: + return vaultKey; default: return specialKey; } @@ -115,6 +118,7 @@ class AlbumListPage extends StatelessWidget { // group ordering if (sections.containsKey(specialKey)) specialKey: sections[specialKey]!, if (sections.containsKey(appsKey)) appsKey: sections[appsKey]!, + if (sections.containsKey(vaultKey)) vaultKey: sections[vaultKey]!, if (sections.containsKey(regularKey)) regularKey: sections[regularKey]!, }; break; diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index 390631ffa..1565080f7 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -3,14 +3,19 @@ import 'dart:io'; import 'package:aves/app_mode.dart'; import 'package:aves/model/actions/chip_set_actions.dart'; import 'package:aves/model/actions/move_type.dart'; +import 'package:aves/model/device.dart'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; +import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums/enums.dart'; import 'package:aves/model/source/enums/view.dart'; +import 'package:aves/model/vaults/details.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -18,10 +23,13 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/aves_app.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; +import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart'; +import 'package:aves/widgets/dialogs/filter_editors/edit_vault_dialog.dart'; import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart'; import 'package:aves/widgets/dialogs/tile_view_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; @@ -32,7 +40,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class AlbumChipSetActionDelegate extends ChipSetActionDelegate with EntryStorageMixin { +class AlbumChipSetActionDelegate extends ChipSetActionDelegate with EntryStorageMixin, VaultAwareMixin { final Iterable> _items; AlbumChipSetActionDelegate(Iterable> items) : _items = items; @@ -73,12 +81,24 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with required int itemCount, required Set selectedFilters, }) { + final selectedSingleItem = selectedFilters.length == 1; + final isMain = appMode == AppMode.main; + + final canCreate = !settings.isReadOnly && appMode.canCreateFilter && !isSelecting; switch (action) { case ChipSetAction.createAlbum: - return !settings.isReadOnly && appMode == AppMode.main && !isSelecting; + return canCreate; + case ChipSetAction.createVault: + return canCreate && device.canUseVaults; case ChipSetAction.delete: case ChipSetAction.rename: - return !settings.isReadOnly && appMode == AppMode.main && isSelecting; + return isMain && isSelecting && !settings.isReadOnly; + case ChipSetAction.hide: + return isMain && selectedFilters.none((v) => vaults.isVault(v.album)); + case ChipSetAction.configureVault: + return isMain && selectedSingleItem && vaults.isVault(selectedFilters.first.album); + case ChipSetAction.lockVault: + return isMain && selectedFilters.any((v) => vaults.isVault(v.album)); default: return super.isVisible( action, @@ -97,14 +117,25 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with required int itemCount, required Set selectedFilters, }) { + final selectedItemCount = selectedFilters.length; + final hasSelection = selectedItemCount > 0; + switch (action) { case ChipSetAction.rename: - { - if (selectedFilters.length != 1) return false; - // do not allow renaming volume root - final dir = VolumeRelativeDirectory.fromPath(selectedFilters.first.album); - return dir != null && dir.relativeDir.isNotEmpty; - } + if (selectedFilters.length != 1) return false; + + final dirPath = selectedFilters.first.album; + if (vaults.isVault(dirPath)) return true; + + // do not allow renaming volume root + final dir = VolumeRelativeDirectory.fromPath(dirPath); + return dir != null && dir.relativeDir.isNotEmpty; + case ChipSetAction.hide: + return hasSelection; + case ChipSetAction.lockVault: + return selectedFilters.map((v) => v.album).any((v) => vaults.isVault(v) && !vaults.isLocked(v)); + case ChipSetAction.configureVault: + return true; default: return super.canApply( action, @@ -121,16 +152,26 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with switch (action) { // general case ChipSetAction.createAlbum: - _createAlbum(context); + _createAlbum(context, locked: false); + break; + case ChipSetAction.createVault: + _createAlbum(context, locked: true); break; // single/multiple filters case ChipSetAction.delete: _delete(context, filters); break; + case ChipSetAction.lockVault: + lockFilters(filters); + _browse(context); + break; // single filter case ChipSetAction.rename: _rename(context, filters.first); break; + case ChipSetAction.configureVault: + _configureVault(context, filters.first); + break; default: break; } @@ -172,51 +213,92 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } } - void _createAlbum(BuildContext context) async { - final newAlbum = await showDialog( - context: context, - builder: (context) => const CreateAlbumDialog(), - routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), - ); - if (newAlbum != null && newAlbum.isNotEmpty) { - final source = context.read(); - source.createAlbum(newAlbum); + void _createAlbum(BuildContext context, {required bool locked}) async { + final l10n = context.l10n; + final source = context.read(); + late final String? directory; + if (locked) { + if (!await showSkippableConfirmationDialog( + context: context, + type: ConfirmationDialog.createVault, + message: l10n.newVaultWarningDialogMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + )) return; - final showAction = SnackBarAction( - label: context.l10n.showButtonLabel, - onPressed: () async { - // local context may be deactivated when action is triggered after navigation - final context = AvesApp.navigatorKey.currentContext; - if (context != null) { - final highlightInfo = context.read(); - final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum)); - if (context.currentRouteName == AlbumListPage.routeName) { - highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter); - } else { - highlightInfo.set(filter); - await Navigator.maybeOf(context)?.pushAndRemoveUntil( - MaterialPageRoute( - settings: const RouteSettings(name: AlbumListPage.routeName), - builder: (_) => const AlbumListPage(), - ), - (route) => false, - ); - } - } - }, + final details = await showDialog( + context: context, + builder: (context) => const EditVaultDialog(), + routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), ); - showFeedback(context, context.l10n.genericSuccessFeedback, showAction); + if (details == null) return; + + await vaults.create(details); + directory = details.path; + } else { + directory = await showDialog( + context: context, + builder: (context) => const CreateAlbumDialog(), + routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), + ); + if (directory == null) return; } + source.createAlbum(directory); + + final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory)); + final showAction = SnackBarAction( + label: l10n.showButtonLabel, + onPressed: () async { + // local context may be deactivated when action is triggered after navigation + final context = AvesApp.navigatorKey.currentContext; + if (context != null) { + final highlightInfo = context.read(); + if (context.currentRouteName == AlbumListPage.routeName) { + highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter); + } else { + highlightInfo.set(filter); + await Navigator.maybeOf(context)?.pushAndRemoveUntil( + MaterialPageRoute( + settings: const RouteSettings(name: AlbumListPage.routeName), + builder: (_) => const AlbumListPage(), + ), + (route) => false, + ); + } + } + }, + ); + showFeedback(context, l10n.genericSuccessFeedback, showAction); } Future _delete(BuildContext context, Set filters) async { + final byBinUsage = groupBy(filters, (filter) { + final details = vaults.getVault(filter.album); + return details?.useBin ?? settings.enableBin; + }); + await Future.forEach( + byBinUsage.entries, + (kv) => _doDelete( + context: context, + filters: kv.value.toSet(), + enableBin: kv.key, + )); + _browse(context); + } + + Future _doDelete({ + required BuildContext context, + required Set filters, + required bool enableBin, + }) async { + if (!await unlockFilters(context, filters)) return; + final source = context.read(); final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet(); final todoAlbums = filters.map((v) => v.album).toSet(); final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet(); final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).toSet(); - if (settings.enableBin && filledAlbums.isNotEmpty) { + if (enableBin && filledAlbums.isNotEmpty) { await doMove( context, moveType: MoveType.toBin, @@ -231,7 +313,6 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } final l10n = context.l10n; - final messenger = ScaffoldMessenger.of(context); final todoCount = todoEntries.length; final confirmed = await showDialog( @@ -255,6 +336,26 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return; + await _deleteEntriesForever(context, todoEntries); + + final vaultAlbumFilters = filters.where((v) => vaults.isVault(v.album)).toSet(); + if (vaultAlbumFilters.isNotEmpty) { + final allEntries = source.allEntries; + final emptyVaultAlbums = vaultAlbumFilters.whereNot((v) => allEntries.any(v.test)).map((v) => v.album).toSet(); + await vaults.remove(emptyVaultAlbums); + } + } + + Future _deleteEntriesForever(BuildContext context, Set todoEntries) async { + if (todoEntries.isEmpty) return; + + final source = context.read(); + final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet(); + + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + final todoCount = todoEntries.length; + source.pauseMonitoring(); final opId = mediaEditService.newOpId; await showOpReport( @@ -283,23 +384,21 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with } Future _rename(BuildContext context, AlbumFilter filter) async { - final l10n = context.l10n; - final messenger = ScaffoldMessenger.of(context); - final source = context.read(); + if (!await unlockFilter(context, filter)) return; + final album = filter.album; - final todoEntries = source.visibleEntries.where(filter.test).toSet(); - final todoCount = todoEntries.length; + if (!vaults.isVault(album)) { + final dir = VolumeRelativeDirectory.fromPath(album); + // do not allow renaming volume root + if (dir == null || dir.relativeDir.isEmpty) return; - final dir = VolumeRelativeDirectory.fromPath(album); - // do not allow renaming volume root - if (dir == null || dir.relativeDir.isEmpty) return; - - // check whether renaming is possible given OS restrictions, - // before asking to input a new name - final restrictedDirs = await storageService.getRestrictedDirectories(); - if (restrictedDirs.contains(dir)) { - await showRestrictedDirectoryDialog(context, dir); - return; + // check whether renaming is possible given OS restrictions, + // before asking to input a new name + final restrictedDirs = await storageService.getRestrictedDirectories(); + if (restrictedDirs.contains(dir)) { + await showRestrictedDirectoryDialog(context, dir); + return; + } } final newName = await showDialog( @@ -309,6 +408,17 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with ); if (newName == null || newName.isEmpty) return; + await _doRename(context, filter, newName); + } + + Future _doRename(BuildContext context, AlbumFilter filter, String newName) async { + final l10n = context.l10n; + final messenger = ScaffoldMessenger.of(context); + final source = context.read(); + final album = filter.album; + final todoEntries = source.visibleEntries.where(filter.test).toSet(); + final todoCount = todoEntries.length; + final destinationAlbumParent = pContext.dirname(album); final destinationAlbum = pContext.join(destinationAlbumParent, newName); if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; @@ -353,4 +463,37 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with }, ); } + + Future _configureVault(BuildContext context, AlbumFilter filter) async { + if (!await unlockFilter(context, filter)) return; + + final oldDetails = vaults.getVault(filter.album); + if (oldDetails == null) return; + + final newDetails = await showDialog( + context: context, + builder: (context) => EditVaultDialog(initialDetails: oldDetails), + routeSettings: const RouteSettings(name: EditVaultDialog.routeName), + ); + if (newDetails == null || oldDetails == newDetails) return; + + if (oldDetails.useBin && !newDetails.useBin) { + final filter = AlbumFilter(oldDetails.path, null); + final source = context.read(); + await _deleteEntriesForever(context, source.trashedEntries.where(filter.test).toSet()); + } + + final oldName = oldDetails.name; + final newName = newDetails.name; + if (oldName != newName) { + await vaults.update(newDetails.copyWith(name: oldName)); + // wipe the old pass, if any, so that it does not overwrite the new pass + // when renaming the vault afterwards + await securityService.writeValue(oldDetails.passKey, null); + await _doRename(context, filter, newName); + } else { + await vaults.update(newDetails); + _browse(context); + } + } } diff --git a/lib/widgets/filter_grids/common/action_delegates/chip.dart b/lib/widgets/filter_grids/common/action_delegates/chip.dart index dab96165f..3679a812c 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip.dart @@ -1,8 +1,12 @@ import 'package:aves/model/actions/chip_actions.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; @@ -11,7 +15,24 @@ import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class ChipActionDelegate { +class ChipActionDelegate with FeedbackMixin, VaultAwareMixin { + bool isVisible( + ChipAction action, { + required CollectionFilter filter, + }) { + switch (action) { + case ChipAction.goToAlbumPage: + case ChipAction.goToCountryPage: + case ChipAction.goToTagPage: + case ChipAction.reverse: + return true; + case ChipAction.hide: + return !(filter is AlbumFilter && vaults.isVault(filter.album)); + case ChipAction.lockVault: + return (filter is AlbumFilter && vaults.isVault(filter.album) && !vaults.isLocked(filter.album)); + } + } + void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) { reportService.log('$action'); switch (action) { @@ -30,8 +51,10 @@ class ChipActionDelegate { case ChipAction.hide: _hide(context, filter); break; - default: - break; + case ChipAction.lockVault: + if (filter is AlbumFilter) { + lockFilters({filter}); + } } } diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index b6a909325..50e7dde04 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -17,6 +17,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart'; @@ -33,7 +34,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -abstract class ChipSetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { +abstract class ChipSetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, VaultAwareMixin { Iterable> get allItems; ChipSortFactor get sortFactor; @@ -88,6 +89,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.toggleTitleSearch: return !useTvLayout && !isSelecting; case ChipSetAction.createAlbum: + case ChipSetAction.createVault: return false; // browsing or selecting case ChipSetAction.map: @@ -95,19 +97,21 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.stats: return isMain; // selecting (single/multiple filters) - case ChipSetAction.delete: - return false; case ChipSetAction.hide: return isMain; case ChipSetAction.pin: return !hasSelection || !settings.pinnedFilters.containsAll(selectedFilters); case ChipSetAction.unpin: return hasSelection && settings.pinnedFilters.containsAll(selectedFilters); - // selecting (single filter) - case ChipSetAction.rename: + case ChipSetAction.delete: + case ChipSetAction.lockVault: return false; + // selecting (single filter) case ChipSetAction.setCover: return isMain; + case ChipSetAction.rename: + case ChipSetAction.configureVault: + return false; } } @@ -131,6 +135,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.search: case ChipSetAction.toggleTitleSearch: case ChipSetAction.createAlbum: + case ChipSetAction.createVault: return true; // browsing or selecting case ChipSetAction.map: @@ -142,10 +147,12 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.hide: case ChipSetAction.pin: case ChipSetAction.unpin: + case ChipSetAction.lockVault: return hasSelection; // selecting (single filter) case ChipSetAction.rename: case ChipSetAction.setCover: + case ChipSetAction.configureVault: return selectedItemCount == 1; } } @@ -174,6 +181,7 @@ abstract class ChipSetActionDelegate with FeedbackMi context.read().toggle(); break; case ChipSetAction.createAlbum: + case ChipSetAction.createVault: break; // browsing or selecting case ChipSetAction.map: @@ -186,8 +194,6 @@ abstract class ChipSetActionDelegate with FeedbackMi _goToStats(context, filters); break; // selecting (single/multiple filters) - case ChipSetAction.delete: - break; case ChipSetAction.hide: _hide(context, filters); break; @@ -199,12 +205,16 @@ abstract class ChipSetActionDelegate with FeedbackMi settings.pinnedFilters = settings.pinnedFilters..removeAll(filters); _browse(context); break; - // selecting (single filter) - case ChipSetAction.rename: + case ChipSetAction.delete: + case ChipSetAction.lockVault: break; + // selecting (single filter) case ChipSetAction.setCover: _setCover(context, filters.first); break; + case ChipSetAction.rename: + case ChipSetAction.configureVault: + break; } } @@ -326,6 +336,8 @@ abstract class ChipSetActionDelegate with FeedbackMi } void _setCover(BuildContext context, T filter) async { + if (!await unlockFilter(context, filter)) return; + final existingCover = covers.of(filter); final entryId = existingCover?.item1; final customEntry = entryId != null ? context.read().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId) : null; diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index 8f96670c9..bf4c56959 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -19,6 +19,7 @@ import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart'; import 'package:aves/widgets/filter_grids/common/query_bar.dart'; import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; @@ -51,7 +52,7 @@ class FilterGridAppBar> createState() => _FilterGridAppBarState(); - static PopupMenuItem toMenuItem(BuildContext context, ChipSetAction action, {required bool enabled}) { + static PopupMenuEntry toMenuItem(BuildContext context, ChipSetAction action, {required bool enabled}) { late Widget child; switch (action) { case ChipSetAction.toggleTitleSearch: @@ -286,7 +287,7 @@ class _FilterGridAppBarState _buildButtonIcon( @@ -326,15 +327,20 @@ class _FilterGridAppBarState !browsingQuickActions.contains(v)); final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v)); - final contextualMenuItems = (isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( - (action) => FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action)), - ); + final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).toList(); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.first == null) contextualMenuActions.removeAt(0); + if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) contextualMenuActions.removeLast(); return [ ...generalMenuItems, - if (contextualMenuItems.isNotEmpty) ...[ + if (contextualMenuActions.isNotEmpty) ...[ const PopupMenuDivider(), - ...contextualMenuItems, + ...contextualMenuActions.map( + (action) { + if (action == null) return const PopupMenuDivider(); + return FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action)); + }, + ), ], ]; }, diff --git a/lib/widgets/filter_grids/common/covered_filter_chip.dart b/lib/widgets/filter_grids/common/covered_filter_chip.dart index 6436cb98a..4bf24cbd9 100644 --- a/lib/widgets/filter_grids/common/covered_filter_chip.dart +++ b/lib/widgets/filter_grids/common/covered_filter_chip.dart @@ -9,9 +9,11 @@ import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/thumbnail/image.dart'; @@ -22,7 +24,7 @@ import 'package:provider/provider.dart'; class CoveredFilterChip extends StatelessWidget { final T filter; final double extent, thumbnailExtent; - final bool showText, pinned; + final bool showText, pinned, locked; final String? banner; final FilterCallback? onTap; final HeroType heroType; @@ -34,6 +36,7 @@ class CoveredFilterChip extends StatelessWidget { double? thumbnailExtent, this.showText = true, this.pinned = false, + required this.locked, this.banner, this.onTap, this.heroType = HeroType.onTap, @@ -98,17 +101,18 @@ class CoveredFilterChip extends StatelessWidget { } Widget _buildChip(BuildContext context, CollectionSource source) { - final entry = source.coverEntry(filter); + final _filter = filter; + final entry = _filter is AlbumFilter && vaults.isLocked(_filter.album) ? null : source.coverEntry(_filter); final titlePadding = min(4.0, extent / 32); Key? chipKey; - if (filter is AlbumFilter) { + if (_filter is AlbumFilter) { // when we asynchronously fetch installed app names, // album filters themselves do not change, but decoration derived from it does chipKey = ValueKey(androidFileUtils.areAppNamesReadyNotifier.value); } return AvesFilterChip( key: chipKey, - filter: filter, + filter: _filter, showText: showText, showGenericIcon: false, decoration: AvesFilterDecoration( @@ -128,10 +132,10 @@ class CoveredFilterChip extends StatelessWidget { }, child: entry == null ? StreamBuilder?>( - stream: covers.colorChangeStream.where((event) => event == null || event.contains(filter)), + stream: covers.colorChangeStream.where((event) => event == null || event.contains(_filter)), builder: (context, snapshot) { return FutureBuilder( - future: filter.color(context), + future: _filter.color(context), builder: (context, snapshot) { final color = snapshot.data; const neutral = Colors.white; @@ -159,7 +163,7 @@ class CoveredFilterChip extends StatelessWidget { radius: radius(extent), ), banner: banner, - details: showText ? _buildDetails(context, source, filter) : null, + details: showText ? _buildDetails(context, source, _filter) : null, padding: titlePadding, heroType: heroType, onTap: onTap, @@ -199,8 +203,18 @@ class CoveredFilterChip extends StatelessWidget { size: iconSize, ), ), + if (filter is AlbumFilter && vaults.isVault(filter.album)) + AnimatedPadding( + padding: EdgeInsetsDirectional.only(end: padding), + duration: Durations.chipDecorationAnimation, + child: Icon( + AIcons.locked, + color: _detailColor(context), + size: iconSize, + ), + ), Text( - numberFormat.format(source.count(filter)), + locked ? Constants.overlayUnknown : numberFormat.format(source.count(filter)), style: TextStyle( color: _detailColor(context), fontSize: fontSize, diff --git a/lib/widgets/filter_grids/common/enums.dart b/lib/widgets/filter_grids/common/enums.dart index dca3f4a68..0113ecfca 100644 --- a/lib/widgets/filter_grids/common/enums.dart +++ b/lib/widgets/filter_grids/common/enums.dart @@ -2,7 +2,7 @@ import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -enum AlbumImportance { newAlbum, pinned, special, apps, regular } +enum AlbumImportance { newAlbum, pinned, special, apps, vaults, regular } extension ExtraAlbumImportance on AlbumImportance { String getText(BuildContext context) { @@ -15,6 +15,8 @@ extension ExtraAlbumImportance on AlbumImportance { return context.l10n.albumTierSpecial; case AlbumImportance.apps: return context.l10n.albumTierApps; + case AlbumImportance.vaults: + return context.l10n.albumTierVaults; case AlbumImportance.regular: return context.l10n.albumTierRegular; } @@ -30,6 +32,8 @@ extension ExtraAlbumImportance on AlbumImportance { return AIcons.important; case AlbumImportance.apps: return AIcons.app; + case AlbumImportance.vaults: + return AIcons.locked; case AlbumImportance.regular: return AIcons.album; } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 940ec352c..9e6ca391a 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -9,6 +9,7 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; @@ -346,57 +347,63 @@ class _FilterGridContentState extends State<_FilterG extent: thumbnailExtent, child: FilterListDetailsTheme( extent: thumbnailExtent, - child: SectionedFilterListLayoutProvider( - sections: visibleSections, - showHeaders: widget.showHeaders, - selectable: widget.selectable, - tileLayout: tileLayout, - scrollableWidth: scrollableWidth, - columnCount: columnCount, - spacing: tileSpacing, - horizontalPadding: horizontalPadding, - tileWidth: thumbnailExtent, - tileHeight: tileHeight, - tileBuilder: (gridItem, tileSize) { - final extent = tileSize.shortestSide; - final tile = InteractiveFilterTile( - gridItem: gridItem, - chipExtent: extent, - thumbnailExtent: extent, + child: AnimatedBuilder( + animation: vaults, + builder: (context, child) { + return SectionedFilterListLayoutProvider( + sections: visibleSections, + showHeaders: widget.showHeaders, + selectable: widget.selectable, tileLayout: tileLayout, - banner: _getFilterBanner(context, gridItem.filter), - heroType: widget.heroType, - ); - if (!settings.useTvLayout) return tile; + scrollableWidth: scrollableWidth, + columnCount: columnCount, + spacing: tileSpacing, + horizontalPadding: horizontalPadding, + tileWidth: thumbnailExtent, + tileHeight: tileHeight, + tileBuilder: (gridItem, tileSize) { + final extent = tileSize.shortestSide; + final tile = InteractiveFilterTile( + gridItem: gridItem, + chipExtent: extent, + thumbnailExtent: extent, + tileLayout: tileLayout, + banner: _getFilterBanner(context, gridItem.filter), + heroType: widget.heroType, + ); + if (!settings.useTvLayout) return tile; - return Focus( - onFocusChange: (focused) { - if (focused) { - _focusedItemNotifier.value = gridItem; - } else if (_focusedItemNotifier.value == gridItem) { - _focusedItemNotifier.value = null; - } + return Focus( + onFocusChange: (focused) { + if (focused) { + _focusedItemNotifier.value = gridItem; + } else if (_focusedItemNotifier.value == gridItem) { + _focusedItemNotifier.value = null; + } + }, + child: ValueListenableBuilder?>( + valueListenable: _focusedItemNotifier, + builder: (context, focusedItem, child) { + return AnimatedScale( + scale: focusedItem == gridItem ? 1 : .9, + curve: Curves.fastOutSlowIn, + duration: context.select((v) => v.tvImageFocusAnimation), + child: child!, + ); + }, + child: tile, + ), + ); }, - child: ValueListenableBuilder?>( - valueListenable: _focusedItemNotifier, - builder: (context, focusedItem, child) { - return AnimatedScale( - scale: focusedItem == gridItem ? 1 : .9, - curve: Curves.fastOutSlowIn, - duration: context.select((v) => v.tvImageFocusAnimation), - child: child!, - ); - }, - child: tile, - ), + tileAnimationDelay: tileAnimationDelay, + coverRatioResolver: (item) { + final coverEntry = source.coverEntry(item.filter) ?? item.entry; + return coverEntry?.displayAspectRatio ?? 1; + }, + child: child!, ); }, - tileAnimationDelay: tileAnimationDelay, - coverRatioResolver: (item) { - final coverEntry = source.coverEntry(item.filter) ?? item.entry; - return coverEntry?.displayAspectRatio ?? 1; - }, - child: child!, + child: child, ), ), ); diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index 4c249180d..5322e6f53 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -1,10 +1,14 @@ import 'package:aves/app_mode.dart'; +import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums/enums.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/grid/scaling.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -35,7 +39,7 @@ class InteractiveFilterTile extends StatefulWidget { State> createState() => _InteractiveFilterTileState(); } -class _InteractiveFilterTileState extends State> { +class _InteractiveFilterTileState extends State> with FeedbackMixin, VaultAwareMixin { HeroType? _heroTypeOverride; FilterGridItem get gridItem => widget.gridItem; @@ -46,7 +50,9 @@ class _InteractiveFilterTileState extends State onTap() async { + if (!await unlockFilter(context, filter)) return; + final appMode = context.read?>()?.value; switch (appMode) { case AppMode.main: @@ -135,6 +141,7 @@ class FilterTile extends StatelessWidget { Widget build(BuildContext context) { final filter = gridItem.filter; final pinned = settings.pinnedFilters.contains(filter); + final locked = filter is AlbumFilter && vaults.isLocked(filter.album); final onChipTap = onTap != null ? (filter) => onTap?.call() : null; switch (tileLayout) { @@ -151,6 +158,7 @@ class FilterTile extends StatelessWidget { thumbnailExtent: thumbnailExtent, showText: true, pinned: pinned, + locked: locked, banner: banner, onTap: onChipTap, heroType: heroType, @@ -170,6 +178,7 @@ class FilterTile extends StatelessWidget { extent: chipExtent, thumbnailExtent: thumbnailExtent, showText: false, + locked: locked, banner: banner, onTap: onChipTap, heroType: heroType, @@ -179,6 +188,7 @@ class FilterTile extends StatelessWidget { child: FilterListDetails( gridItem: gridItem, pinned: pinned, + locked: locked, ), ), ], diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart index 8bce14199..84c11a2e3 100644 --- a/lib/widgets/filter_grids/common/list_details.dart +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -16,7 +16,7 @@ import 'package:provider/provider.dart'; class FilterListDetails extends StatelessWidget { final FilterGridItem gridItem; - final bool pinned; + final bool pinned, locked; T get filter => gridItem.filter; @@ -26,6 +26,7 @@ class FilterListDetails extends StatelessWidget { super.key, required this.gridItem, required this.pinned, + required this.locked, }); @override @@ -72,9 +73,11 @@ class FilterListDetails extends StatelessWidget { // otherwise the leading icon will be low-res scaled up/down textScaleFactor: 1, ), - const SizedBox(height: FilterListDetailsTheme.titleDetailPadding), - if (detailsTheme.showDate) _buildDateRow(context, detailsTheme, hasTitleLeading), - if (detailsTheme.showCount) _buildCountRow(context, detailsTheme, hasTitleLeading), + if (!locked) ...[ + const SizedBox(height: FilterListDetailsTheme.titleDetailPadding), + if (detailsTheme.showDate) _buildDateRow(context, detailsTheme, hasTitleLeading), + if (detailsTheme.showCount) _buildCountRow(context, detailsTheme, hasTitleLeading), + ], ], ), ); diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index 5f700f9c1..a93185c39 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -32,6 +32,8 @@ class AlbumImportanceSectionKey extends ChipSectionKey { factory AlbumImportanceSectionKey.apps(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.apps); + factory AlbumImportanceSectionKey.vault(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.vaults); + factory AlbumImportanceSectionKey.regular(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.regular); @override diff --git a/lib/widgets/navigation/drawer/app_drawer.dart b/lib/widgets/navigation/drawer/app_drawer.dart index e88747b4c..d2ab4380f 100644 --- a/lib/widgets/navigation/drawer/app_drawer.dart +++ b/lib/widgets/navigation/drawer/app_drawer.dart @@ -107,11 +107,10 @@ class _AppDrawerState extends State { Future goTo(String routeName, WidgetBuilder pageBuilder) async { Navigator.maybeOf(context)?.pop(); await Future.delayed(Durations.drawerTransitionAnimation); - await Navigator.maybeOf(context)?.push( - MaterialPageRoute( - settings: RouteSettings(name: routeName), - builder: pageBuilder, - )); + await Navigator.maybeOf(context)?.push(MaterialPageRoute( + settings: RouteSettings(name: routeName), + builder: pageBuilder, + )); } return Container( diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 83c5f28e8..bd6705ca9 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -19,6 +19,8 @@ import 'package:aves/model/source/location.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/basic/tv_edge_focus.dart'; import 'package:aves/widgets/common/expandable_filter_row.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -30,7 +32,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -class CollectionSearchDelegate extends AvesSearchDelegate { +class CollectionSearchDelegate extends AvesSearchDelegate with FeedbackMixin, VaultAwareMixin { final CollectionSource source; final CollectionLens? parentCollection; final ValueNotifier _expandedSectionNotifier = ValueNotifier(null); @@ -285,12 +287,14 @@ class CollectionSearchDelegate extends AvesSearchDelegate { return cleanQuery.isNotEmpty ? QueryFilter(cleanQuery, colorful: colorful) : null; } - void _select(BuildContext context, CollectionFilter? filter) { + Future _select(BuildContext context, CollectionFilter? filter) async { if (filter == null) { goBack(context); return; } + if (!await unlockFilter(context, filter)) return; + if (settings.saveSearchHistory) { final history = settings.searchHistory ..remove(filter) diff --git a/lib/widgets/settings/navigation/confirmation_dialogs.dart b/lib/widgets/settings/navigation/confirmation_dialogs.dart index 59c051bd1..2e80643e3 100644 --- a/lib/widgets/settings/navigation/confirmation_dialogs.dart +++ b/lib/widgets/settings/navigation/confirmation_dialogs.dart @@ -39,6 +39,12 @@ class ConfirmationDialogPage extends StatelessWidget { onChanged: (v) => settings.confirmAfterMoveToBin = v, title: l10n.settingsConfirmationAfterMoveToBinItems, ), + const Divider(height: 32), + SettingsSwitchListTile( + selector: (context, s) => s.confirmCreateVault, + onChanged: (v) => settings.confirmCreateVault = v, + title: l10n.settingsConfirmationVaultDataLoss, + ), ]), ), ); diff --git a/lib/widgets/settings/privacy/privacy.dart b/lib/widgets/settings/privacy/privacy.dart index 31f002afe..38088cd6d 100644 --- a/lib/widgets/settings/privacy/privacy.dart +++ b/lib/widgets/settings/privacy/privacy.dart @@ -3,10 +3,15 @@ import 'dart:async'; import 'package:aves/app_flavor.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/settings/common/tile_leading.dart'; import 'package:aves/widgets/settings/common/tiles.dart'; import 'package:aves/widgets/settings/privacy/access_grants_page.dart'; @@ -92,7 +97,41 @@ class SettingsTilePrivacyEnableBin extends SettingsTile { @override Widget build(BuildContext context) => SettingsSwitchListTile( selector: (context, s) => s.enableBin, - onChanged: (v) { + onChanged: (v) async { + final l10n = context.l10n; + if (!v) { + if (vaults.all.any((v) => v.useBin)) { + await showDialog( + context: context, + builder: (context) => AvesDialog( + content: Text(l10n.vaultBinUsageDialogMessage), + actions: const [OkButton()], + ), + ); + return; + } + + final source = context.read(); + final trashedEntries = source.trashedEntries; + if (trashedEntries.isNotEmpty) { + if (!await showConfirmationDialog( + context: context, + message: l10n.settingsDisablingBinWarningDialogMessage, + confirmationButtonLabel: l10n.applyButtonLabel, + )) return; + + // delete forever trashed items + await EntrySetActionDelegate().doDelete( + context: context, + entries: trashedEntries, + enableBin: false, + ); + + // in case of failure or cancellation + if (source.trashedEntries.isNotEmpty) return; + } + } + settings.enableBin = v; if (!v) { settings.searchHistory = []; diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 20c7a118d..ac28daf8d 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -14,6 +14,8 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/scaffold.dart'; import 'package:aves/widgets/common/basic/tv_edge_focus.dart'; @@ -51,7 +53,7 @@ class StatsPage extends StatefulWidget { State createState() => _StatsPageState(); } -class _StatsPageState extends State { +class _StatsPageState extends State with FeedbackMixin, VaultAwareMixin { final Map _entryCountPerCountry = {}, _entryCountPerPlace = {}, _entryCountPerTag = {}, _entryCountPerAlbum = {}; final Map _entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0))); late final ValueNotifier _isPageAnimatingNotifier; @@ -319,7 +321,9 @@ class _StatsPageState extends State { ]; } - void _onFilterSelection(BuildContext context, CollectionFilter filter) { + Future _onFilterSelection(BuildContext context, CollectionFilter filter) async { + if (!await unlockFilter(context, filter)) return; + if (widget.parentCollection != null) { _applyToParentCollectionPage(context, filter); } else { diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index 6ae0035f1..0003dc347 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -14,6 +14,7 @@ import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -25,6 +26,7 @@ import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart'; +import 'package:aves/widgets/common/action_mixins/vault_aware.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; @@ -47,7 +49,7 @@ import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; -class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin { +class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin, VaultAwareMixin { final AvesEntry mainEntry, pageEntry; final CollectionLens? collection; final EntryInfoActionDelegate _metadataActionDelegate = EntryInfoActionDelegate(); @@ -290,11 +292,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } } - void quickMove(BuildContext context, String album, {required bool copy}) { + Future quickMove(BuildContext context, String album, {required bool copy}) async { + if (!await unlockAlbum(context, album)) return; + final targetEntry = _getTargetEntry(context, copy ? EntryAction.copy : EntryAction.move); if (!copy && targetEntry.directory == album) return; - doQuickMove( + await doQuickMove( context, moveType: copy ? MoveType.copy : MoveType.move, entriesByDestination: { @@ -379,13 +383,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } Future _delete(BuildContext context, AvesEntry targetEntry) async { - if (settings.enableBin && !targetEntry.trashed) { + final vault = vaults.getVault(targetEntry.directory); + final enableBin = vault?.useBin ?? settings.enableBin; + + if (enableBin && !targetEntry.trashed) { await _move(context, targetEntry, moveType: MoveType.toBin); return; } final l10n = context.l10n; - if (!await showConfirmationDialog( + if (!await showSkippableConfirmationDialog( context: context, type: ConfirmationDialog.deleteForever, message: l10n.deleteEntriesConfirmationDialogMessage(1), @@ -446,14 +453,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix nameConflictStrategy: NameConflictStrategy.rename, ), itemCount: selectionCount, - onDone: (processed) { + onDone: (processed) async { final successOps = processed.where((e) => e.success).toSet(); final exportedOps = successOps.where((e) => !e.skipped).toSet(); final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet(); final isMainMode = context.read>().value == AppMode.main; source.resumeMonitoring(); - source.refreshUris(newUris); + unawaited(source.refreshUris(newUris)); final l10n = context.l10n; final showAction = isMainMode && newUris.isNotEmpty diff --git a/lib/widgets/viewer/debug/db.dart b/lib/widgets/viewer/debug/db.dart index f74a163d0..a7a039e8e 100644 --- a/lib/widgets/viewer/debug/db.dart +++ b/lib/widgets/viewer/debug/db.dart @@ -39,7 +39,7 @@ class _DbTabState extends State { void _loadDatabase() { final id = entry.id; _dbDateLoader = metadataDb.loadDates().then((values) => values[id]); - _dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.id == id)); + _dbEntryLoader = metadataDb.loadEntriesById({id}).then((values) => values.firstOrNull); _dbMetadataLoader = metadataDb.loadCatalogMetadata().then((values) => values.firstWhereOrNull((row) => row.id == id)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.id == id)); _dbTrashDetailsLoader = metadataDb.loadAllTrashDetails().then((values) => values.firstWhereOrNull((row) => row.id == id)); diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index cf89f3ed2..42dce44c1 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -69,6 +69,7 @@ class ViewerDebugPage extends StatelessWidget { info: { 'hash': '#${shortHash(entry)}', 'id': '${entry.id}', + 'origin': '${entry.origin}', 'contentId': '${entry.contentId}', 'uri': entry.uri, 'path': entry.path ?? '', diff --git a/plugins/aves_platform_meta/android/build.gradle b/plugins/aves_platform_meta/android/build.gradle index 435bd2270..097b07644 100644 --- a/plugins/aves_platform_meta/android/build.gradle +++ b/plugins/aves_platform_meta/android/build.gradle @@ -2,14 +2,14 @@ group 'deckers.thibault.aves.aves_platform_meta' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.7.20' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' + classpath 'com.android.tools.build:gradle:7.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/plugins/aves_platform_meta/android/gradle/wrapper/gradle-wrapper.properties b/plugins/aves_platform_meta/android/gradle/wrapper/gradle-wrapper.properties index 080650cd5..cb92fa5fd 100644 --- a/plugins/aves_platform_meta/android/gradle/wrapper/gradle-wrapper.properties +++ b/plugins/aves_platform_meta/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 815197372..6c8596cb0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -627,6 +627,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "8cea55dca20d1e0efa5480df2d47ae30851e7a24cb8e7d225be7e67ae8485aa4" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: cfcbc4936e288d61ef85a04feef6b95f49ba496d4fd98364e6abafb462b06a1f + url: "https://pub.dev" + source: hosted + version: "1.0.18" + local_auth_ios: + dependency: transitive + description: + name: local_auth_ios + sha256: aa32478d7513066564139af57e11e2cad1bbd535c1efd224a88a8764c5665e3b + url: "https://pub.dev" + source: hosted + version: "1.0.12" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: fbb6973f2fd088e2677f39a5ab550aa1cfbc00997859d5e865569872499d6d61 + url: "https://pub.dev" + source: hosted + version: "1.0.6" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: "888482e4f9ca3560e00bc227ce2badeb4857aad450c42a31c6cfc9dc21e0ccbc" + url: "https://pub.dev" + source: hosted + version: "1.0.5" logging: dependency: transitive description: @@ -877,6 +917,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.0" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: e6aabd1571dde622f9b942f62ac2c80f84b0b50f95fa209a93e78f7d621e1f82 + url: "https://pub.dev" + source: hosted + version: "2.2.23" platform: dependency: transitive description: @@ -1013,6 +1061,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.1" + screen_state: + dependency: "direct main" + description: + name: screen_state + sha256: "39184c718baf303f26200f6b1392b12a549d88410e907e046d75594588c0df5d" + url: "https://pub.dev" + source: hosted + version: "2.0.0" shared_preferences: dependency: "direct main" description: @@ -1106,6 +1162,14 @@ packages: description: flutter source: sdk version: "0.0.99" + smart_auth: + dependency: transitive + description: + name: smart_auth + sha256: "8cfaec55b77d5930ed1666bb7ae70db5bade099bb1422401386853b400962113" + url: "https://pub.dev" + source: hosted + version: "1.0.8" smooth_page_indicator: dependency: "direct main" description: @@ -1275,6 +1339,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc + url: "https://pub.dev" + source: hosted + version: "1.0.0+1" url_launcher: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7ecf2f109..cd42672a6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: get_it: intl: latlong2: + local_auth: material_color_utilities: material_design_icons_flutter: overlay_support: @@ -82,10 +83,12 @@ dependencies: pdf: percent_indicator: permission_handler: + pinput: printing: proj4dart: provider: screen_brightness: + screen_state: shared_preferences: smooth_page_indicator: sqflite: diff --git a/test/fake/device_service.dart b/test/fake/device_service.dart index c8afba4ec..911aff66f 100644 --- a/test/fake/device_service.dart +++ b/test/fake/device_service.dart @@ -4,5 +4,5 @@ import 'package:test/fake.dart'; class FakeDeviceService extends Fake implements DeviceService { @override - Future getDefaultTimeZone() => SynchronousFuture(''); + Future getDefaultTimeZoneRawOffsetMillis() => SynchronousFuture(3600000); } diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index 0649476ab..eb9082ee2 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -39,6 +39,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { contentId ??= id; final date = dateSecs; return AvesEntry( + origin: EntryOrigins.mediaStoreContent, id: id, uri: 'content://media/external/images/media/$contentId', path: '$album/$filenameWithoutExtension.jpg', diff --git a/test/fake/metadata_db.dart b/test/fake/metadata_db.dart index 1d8fdc1a1..3cfa17443 100644 --- a/test/fake/metadata_db.dart +++ b/test/fake/metadata_db.dart @@ -19,15 +19,15 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future init() => SynchronousFuture(null); @override - Future removeIds(Iterable ids, {Set? dataTypes}) => SynchronousFuture(null); + Future removeIds(Set ids, {Set? dataTypes}) => SynchronousFuture(null); // entries @override - Future> loadEntries({String? directory}) => SynchronousFuture({}); + Future> loadEntries({int? origin, String? directory}) => SynchronousFuture({}); @override - Future saveEntries(Iterable entries) => SynchronousFuture(null); + Future saveEntries(Set entries) => SynchronousFuture(null); @override Future updateEntry(int id, AvesEntry entry) => SynchronousFuture(null); @@ -76,13 +76,13 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future> loadAllFavourites() => SynchronousFuture({}); @override - Future addFavourites(Iterable rows) => SynchronousFuture(null); + Future addFavourites(Set rows) => SynchronousFuture(null); @override Future updateFavouriteId(int id, FavouriteRow row) => SynchronousFuture(null); @override - Future removeFavourites(Iterable rows) => SynchronousFuture(null); + Future removeFavourites(Set rows) => SynchronousFuture(null); // covers @@ -90,7 +90,7 @@ class FakeMetadataDb extends Fake implements MetadataDb { Future> loadAllCovers() => SynchronousFuture({}); @override - Future addCovers(Iterable rows) => SynchronousFuture(null); + Future addCovers(Set rows) => SynchronousFuture(null); @override Future updateCoverEntryId(int id, CoverRow row) => SynchronousFuture(null); @@ -101,5 +101,5 @@ class FakeMetadataDb extends Fake implements MetadataDb { // video playback @override - Future removeVideoPlayback(Iterable ids) => SynchronousFuture(null); + Future removeVideoPlayback(Set ids) => SynchronousFuture(null); } diff --git a/untranslated.json b/untranslated.json index 58fbe8d12..a67628edc 100644 --- a/untranslated.json +++ b/untranslated.json @@ -18,11 +18,14 @@ "chipActionFilterOut", "chipActionFilterIn", "chipActionHide", + "chipActionLock", "chipActionPin", "chipActionUnpin", "chipActionRename", "chipActionSetCover", "chipActionCreateAlbum", + "chipActionCreateVault", + "chipActionConfigureVault", "entryActionCopyToClipboard", "entryActionDelete", "entryActionConvert", @@ -91,6 +94,14 @@ "filterTypeGeotiffLabel", "filterMimeImageLabel", "filterMimeVideoLabel", + "accessibilityAnimationsRemove", + "accessibilityAnimationsKeep", + "albumTierNew", + "albumTierPinned", + "albumTierSpecial", + "albumTierApps", + "albumTierVaults", + "albumTierRegular", "coordinateFormatDms", "coordinateFormatDecimal", "coordinateDms", @@ -98,15 +109,12 @@ "coordinateDmsSouth", "coordinateDmsEast", "coordinateDmsWest", - "unitSystemMetric", - "unitSystemImperial", - "videoLoopModeNever", - "videoLoopModeShortOnly", - "videoLoopModeAlways", - "videoControlsPlay", - "videoControlsPlaySeek", - "videoControlsPlayOutside", - "videoControlsNone", + "displayRefreshRatePreferHighest", + "displayRefreshRatePreferLowest", + "keepScreenOnNever", + "keepScreenOnVideoPlayback", + "keepScreenOnViewerOnly", + "keepScreenOnAlways", "mapStyleGoogleNormal", "mapStyleGoogleHybrid", "mapStyleGoogleTerrain", @@ -118,22 +126,25 @@ "nameConflictStrategyRename", "nameConflictStrategyReplace", "nameConflictStrategySkip", - "keepScreenOnNever", - "keepScreenOnVideoPlayback", - "keepScreenOnViewerOnly", - "keepScreenOnAlways", - "accessibilityAnimationsRemove", - "accessibilityAnimationsKeep", - "displayRefreshRatePreferHighest", - "displayRefreshRatePreferLowest", "subtitlePositionTop", "subtitlePositionBottom", - "videoPlaybackSkip", - "videoPlaybackMuted", - "videoPlaybackWithSound", "themeBrightnessLight", "themeBrightnessDark", "themeBrightnessBlack", + "unitSystemMetric", + "unitSystemImperial", + "vaultLockTypePin", + "vaultLockTypePassword", + "videoControlsPlay", + "videoControlsPlaySeek", + "videoControlsPlayOutside", + "videoControlsNone", + "videoLoopModeNever", + "videoLoopModeShortOnly", + "videoLoopModeAlways", + "videoPlaybackSkip", + "videoPlaybackMuted", + "videoPlaybackWithSound", "viewerTransitionSlide", "viewerTransitionParallax", "viewerTransitionFade", @@ -147,11 +158,6 @@ "widgetOpenPageHome", "widgetOpenPageCollection", "widgetOpenPageViewer", - "albumTierNew", - "albumTierPinned", - "albumTierSpecial", - "albumTierApps", - "albumTierRegular", "storageVolumeDescriptionFallbackPrimary", "storageVolumeDescriptionFallbackNonPrimary", "rootDirectoryDescription", @@ -181,6 +187,18 @@ "newAlbumDialogNameLabel", "newAlbumDialogNameLabelAlreadyExistsHelper", "newAlbumDialogStorageLabel", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "renameAlbumDialogLabel", "renameAlbumDialogLabelAlreadyExistsHelper", "renameEntrySetPageTitle", @@ -347,7 +365,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -391,6 +408,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -483,6 +501,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -571,13 +590,128 @@ "tagEditorPageNewTagFieldLabel" ], + "cs": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + + "de": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "el": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", - "settingsVideoGestureVerticalDragBrightnessVolume" + "settingsConfirmationVaultDataLoss", + "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage" + ], + + "es": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + + "eu": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" ], "fa": [ "clearTooltip", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "videoActionPause", "videoActionPlay", "videoActionSelectStreams", @@ -590,6 +724,13 @@ "filterTypeAnimatedLabel", "filterTypeRawLabel", "filterTypeGeotiffLabel", + "accessibilityAnimationsRemove", + "accessibilityAnimationsKeep", + "albumTierNew", + "albumTierPinned", + "albumTierSpecial", + "albumTierVaults", + "albumTierRegular", "coordinateFormatDms", "coordinateFormatDecimal", "coordinateDms", @@ -597,14 +738,14 @@ "coordinateDmsSouth", "coordinateDmsEast", "coordinateDmsWest", - "videoControlsNone", - "nameConflictStrategySkip", "keepScreenOnNever", "keepScreenOnVideoPlayback", "keepScreenOnViewerOnly", "keepScreenOnAlways", - "accessibilityAnimationsRemove", - "accessibilityAnimationsKeep", + "nameConflictStrategySkip", + "vaultLockTypePin", + "vaultLockTypePassword", + "videoControlsNone", "videoPlaybackSkip", "viewerTransitionSlide", "viewerTransitionParallax", @@ -619,10 +760,6 @@ "widgetOpenPageHome", "widgetOpenPageCollection", "widgetOpenPageViewer", - "albumTierNew", - "albumTierPinned", - "albumTierSpecial", - "albumTierRegular", "rootDirectoryDescription", "otherDirectoryDescription", "restrictedAccessDialogMessage", @@ -647,6 +784,18 @@ "newAlbumDialogNameLabel", "newAlbumDialogNameLabelAlreadyExistsHelper", "newAlbumDialogStorageLabel", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "renameAlbumDialogLabel", "renameAlbumDialogLabelAlreadyExistsHelper", "renameEntrySetPageTitle", @@ -787,7 +936,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -827,6 +975,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -919,6 +1068,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -1018,8 +1168,34 @@ "filePickerUseThisFolder" ], + "fr": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "gl": [ "columnCount", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "entryActionShareImageOnly", "entryActionShareVideoOnly", "entryInfoActionExportMetadata", @@ -1029,19 +1205,27 @@ "filterNoAddressLabel", "filterLocatedLabel", "filterTaggedLabel", - "keepScreenOnVideoPlayback", "accessibilityAnimationsRemove", "accessibilityAnimationsKeep", + "albumTierNew", + "albumTierPinned", + "albumTierSpecial", + "albumTierApps", + "albumTierVaults", + "albumTierRegular", "displayRefreshRatePreferHighest", "displayRefreshRatePreferLowest", + "keepScreenOnVideoPlayback", "subtitlePositionTop", "subtitlePositionBottom", - "videoPlaybackSkip", - "videoPlaybackMuted", - "videoPlaybackWithSound", "themeBrightnessLight", "themeBrightnessDark", "themeBrightnessBlack", + "vaultLockTypePin", + "vaultLockTypePassword", + "videoPlaybackSkip", + "videoPlaybackMuted", + "videoPlaybackWithSound", "viewerTransitionSlide", "viewerTransitionParallax", "viewerTransitionFade", @@ -1055,11 +1239,6 @@ "widgetOpenPageHome", "widgetOpenPageCollection", "widgetOpenPageViewer", - "albumTierNew", - "albumTierPinned", - "albumTierSpecial", - "albumTierApps", - "albumTierRegular", "storageVolumeDescriptionFallbackPrimary", "storageVolumeDescriptionFallbackNonPrimary", "rootDirectoryDescription", @@ -1089,6 +1268,18 @@ "newAlbumDialogNameLabel", "newAlbumDialogNameLabelAlreadyExistsHelper", "newAlbumDialogStorageLabel", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "renameAlbumDialogLabel", "renameAlbumDialogLabelAlreadyExistsHelper", "renameEntrySetPageTitle", @@ -1255,7 +1446,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -1299,6 +1489,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -1391,6 +1582,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -1529,11 +1721,14 @@ "chipActionFilterOut", "chipActionFilterIn", "chipActionHide", + "chipActionLock", "chipActionPin", "chipActionUnpin", "chipActionRename", "chipActionSetCover", "chipActionCreateAlbum", + "chipActionCreateVault", + "chipActionConfigureVault", "entryActionCopyToClipboard", "entryActionDelete", "entryActionConvert", @@ -1602,6 +1797,14 @@ "filterTypeGeotiffLabel", "filterMimeImageLabel", "filterMimeVideoLabel", + "accessibilityAnimationsRemove", + "accessibilityAnimationsKeep", + "albumTierNew", + "albumTierPinned", + "albumTierSpecial", + "albumTierApps", + "albumTierVaults", + "albumTierRegular", "coordinateFormatDms", "coordinateFormatDecimal", "coordinateDms", @@ -1609,15 +1812,12 @@ "coordinateDmsSouth", "coordinateDmsEast", "coordinateDmsWest", - "unitSystemMetric", - "unitSystemImperial", - "videoLoopModeNever", - "videoLoopModeShortOnly", - "videoLoopModeAlways", - "videoControlsPlay", - "videoControlsPlaySeek", - "videoControlsPlayOutside", - "videoControlsNone", + "displayRefreshRatePreferHighest", + "displayRefreshRatePreferLowest", + "keepScreenOnNever", + "keepScreenOnVideoPlayback", + "keepScreenOnViewerOnly", + "keepScreenOnAlways", "mapStyleGoogleNormal", "mapStyleGoogleHybrid", "mapStyleGoogleTerrain", @@ -1629,22 +1829,25 @@ "nameConflictStrategyRename", "nameConflictStrategyReplace", "nameConflictStrategySkip", - "keepScreenOnNever", - "keepScreenOnVideoPlayback", - "keepScreenOnViewerOnly", - "keepScreenOnAlways", - "accessibilityAnimationsRemove", - "accessibilityAnimationsKeep", - "displayRefreshRatePreferHighest", - "displayRefreshRatePreferLowest", "subtitlePositionTop", "subtitlePositionBottom", - "videoPlaybackSkip", - "videoPlaybackMuted", - "videoPlaybackWithSound", "themeBrightnessLight", "themeBrightnessDark", "themeBrightnessBlack", + "unitSystemMetric", + "unitSystemImperial", + "vaultLockTypePin", + "vaultLockTypePassword", + "videoControlsPlay", + "videoControlsPlaySeek", + "videoControlsPlayOutside", + "videoControlsNone", + "videoLoopModeNever", + "videoLoopModeShortOnly", + "videoLoopModeAlways", + "videoPlaybackSkip", + "videoPlaybackMuted", + "videoPlaybackWithSound", "viewerTransitionSlide", "viewerTransitionParallax", "viewerTransitionFade", @@ -1658,11 +1861,6 @@ "widgetOpenPageHome", "widgetOpenPageCollection", "widgetOpenPageViewer", - "albumTierNew", - "albumTierPinned", - "albumTierSpecial", - "albumTierApps", - "albumTierRegular", "storageVolumeDescriptionFallbackPrimary", "storageVolumeDescriptionFallbackNonPrimary", "rootDirectoryDescription", @@ -1692,6 +1890,18 @@ "newAlbumDialogNameLabel", "newAlbumDialogNameLabelAlreadyExistsHelper", "newAlbumDialogStorageLabel", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "renameAlbumDialogLabel", "renameAlbumDialogLabelAlreadyExistsHelper", "renameEntrySetPageTitle", @@ -1858,7 +2068,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -1902,6 +2111,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -1994,6 +2204,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -2095,41 +2306,176 @@ "filePickerUseThisFolder" ], + "id": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + + "it": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "ja": [ "columnCount", "chipActionFilterIn", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "filterAspectRatioLandscapeLabel", "filterAspectRatioPortraitLabel", "filterNoAddressLabel", "filterLocatedLabel", "filterTaggedLabel", + "albumTierVaults", "keepScreenOnVideoPlayback", "subtitlePositionTop", "subtitlePositionBottom", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface", "settingsWidgetDisplayedItem" ], + "ko": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "lt": [ "columnCount", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "filterLocatedLabel", "filterTaggedLabel", + "albumTierVaults", "keepScreenOnVideoPlayback", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ], + "nb": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "nl": [ "columnCount", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "entryActionShareImageOnly", "entryActionShareVideoOnly", "entryInfoActionExportMetadata", @@ -2139,18 +2485,35 @@ "filterNoAddressLabel", "filterLocatedLabel", "filterTaggedLabel", + "albumTierVaults", "keepScreenOnVideoPlayback", "subtitlePositionTop", "subtitlePositionBottom", + "vaultLockTypePin", + "vaultLockTypePassword", "widgetDisplayedItemRandom", "widgetDisplayedItemMostRecent", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsConfirmationVaultDataLoss", "settingsViewerShowRatingTags", "settingsViewerShowDescription", "settingsSubtitleThemeTextPositionTile", "settingsSubtitleThemeTextPositionDialogTitle", "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface", "settingsWidgetDisplayedItem" @@ -2159,16 +2522,34 @@ "nn": [ "columnCount", "sourceStateCataloguing", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "entryInfoActionRemoveLocation", "filterLocatedLabel", "filterNoLocationLabel", "filterTaggedLabel", "accessibilityAnimationsKeep", + "albumTierVaults", "displayRefreshRatePreferHighest", "displayRefreshRatePreferLowest", + "vaultLockTypePin", + "vaultLockTypePassword", "wallpaperTargetHome", "wallpaperTargetHomeLock", "setCoverDialogCustom", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "editEntryDialogTargetFieldsHeader", "editEntryDateDialogSetCustom", "editEntryLocationDialogTitle", @@ -2271,7 +2652,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -2315,6 +2695,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -2407,6 +2788,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -2444,18 +2826,104 @@ "wallpaperUseScrollEffect" ], + "pl": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "pt": [ "columnCount", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", - "settingsVideoGestureVerticalDragBrightnessVolume" + "settingsConfirmationVaultDataLoss", + "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage" + ], + + "ro": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" ], "ru": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "filterLocatedLabel", "filterTaggedLabel", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsConfirmationVaultDataLoss", "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage", "settingsDisplayUseTvInterface" ], @@ -2463,6 +2931,9 @@ "itemCount", "columnCount", "timeSeconds", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "entryActionOpenMap", "entryActionRotateScreen", "entryActionAddFavourite", @@ -2510,6 +2981,14 @@ "filterTypeGeotiffLabel", "filterMimeImageLabel", "filterMimeVideoLabel", + "accessibilityAnimationsRemove", + "accessibilityAnimationsKeep", + "albumTierNew", + "albumTierPinned", + "albumTierSpecial", + "albumTierApps", + "albumTierVaults", + "albumTierRegular", "coordinateFormatDms", "coordinateFormatDecimal", "coordinateDms", @@ -2517,15 +2996,12 @@ "coordinateDmsSouth", "coordinateDmsEast", "coordinateDmsWest", - "unitSystemMetric", - "unitSystemImperial", - "videoLoopModeNever", - "videoLoopModeShortOnly", - "videoLoopModeAlways", - "videoControlsPlay", - "videoControlsPlaySeek", - "videoControlsPlayOutside", - "videoControlsNone", + "displayRefreshRatePreferHighest", + "displayRefreshRatePreferLowest", + "keepScreenOnNever", + "keepScreenOnVideoPlayback", + "keepScreenOnViewerOnly", + "keepScreenOnAlways", "mapStyleGoogleNormal", "mapStyleGoogleHybrid", "mapStyleGoogleTerrain", @@ -2537,22 +3013,25 @@ "nameConflictStrategyRename", "nameConflictStrategyReplace", "nameConflictStrategySkip", - "keepScreenOnNever", - "keepScreenOnVideoPlayback", - "keepScreenOnViewerOnly", - "keepScreenOnAlways", - "accessibilityAnimationsRemove", - "accessibilityAnimationsKeep", - "displayRefreshRatePreferHighest", - "displayRefreshRatePreferLowest", "subtitlePositionTop", "subtitlePositionBottom", - "videoPlaybackSkip", - "videoPlaybackMuted", - "videoPlaybackWithSound", "themeBrightnessLight", "themeBrightnessDark", "themeBrightnessBlack", + "unitSystemMetric", + "unitSystemImperial", + "vaultLockTypePin", + "vaultLockTypePassword", + "videoControlsPlay", + "videoControlsPlaySeek", + "videoControlsPlayOutside", + "videoControlsNone", + "videoLoopModeNever", + "videoLoopModeShortOnly", + "videoLoopModeAlways", + "videoPlaybackSkip", + "videoPlaybackMuted", + "videoPlaybackWithSound", "viewerTransitionSlide", "viewerTransitionParallax", "viewerTransitionFade", @@ -2566,11 +3045,6 @@ "widgetOpenPageHome", "widgetOpenPageCollection", "widgetOpenPageViewer", - "albumTierNew", - "albumTierPinned", - "albumTierSpecial", - "albumTierApps", - "albumTierRegular", "storageVolumeDescriptionFallbackPrimary", "storageVolumeDescriptionFallbackNonPrimary", "rootDirectoryDescription", @@ -2600,6 +3074,18 @@ "newAlbumDialogNameLabel", "newAlbumDialogNameLabelAlreadyExistsHelper", "newAlbumDialogStorageLabel", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "renameAlbumDialogLabel", "renameAlbumDialogLabelAlreadyExistsHelper", "renameEntrySetPageTitle", @@ -2766,7 +3252,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -2810,6 +3295,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -2902,6 +3388,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -3011,6 +3498,24 @@ "timeDays", "focalLength", "applyButtonLabel", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "editEntryDateDialogExtractFromTitle", "editEntryDateDialogShift", "removeEntryMetadataDialogTitle", @@ -3092,7 +3597,6 @@ "albumVideoCaptures", "albumPageTitle", "albumEmpty", - "createAlbumTooltip", "createAlbumButtonLabel", "newFilterBanner", "countryPageTitle", @@ -3136,6 +3640,7 @@ "settingsConfirmationBeforeMoveToBinItems", "settingsConfirmationBeforeMoveUndatedItems", "settingsConfirmationAfterMoveToBinItems", + "settingsConfirmationVaultDataLoss", "settingsNavigationDrawerTile", "settingsNavigationDrawerEditorPageTitle", "settingsNavigationDrawerBanner", @@ -3228,6 +3733,7 @@ "settingsSaveSearchHistory", "settingsEnableBin", "settingsEnableBinSubtitle", + "settingsDisablingBinWarningDialogMessage", "settingsAllowMediaManagement", "settingsHiddenItemsTile", "settingsHiddenItemsPageTitle", @@ -3329,25 +3835,111 @@ "filePickerUseThisFolder" ], + "tr": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + + "uk": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", + "settingsConfirmationVaultDataLoss", + "settingsDisablingBinWarningDialogMessage" + ], + "zh": [ + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "filterLocatedLabel", "filterTaggedLabel", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ], "zh_Hant": [ "columnCount", + "chipActionLock", + "chipActionCreateVault", + "chipActionConfigureVault", "filterLocatedLabel", "filterTaggedLabel", + "albumTierVaults", + "vaultLockTypePin", + "vaultLockTypePassword", + "newVaultWarningDialogMessage", + "newVaultDialogTitle", + "configureVaultDialogTitle", + "vaultDialogLockModeWhenScreenOff", + "vaultDialogLockTypeLabel", + "pinDialogEnter", + "pinDialogConfirm", + "passwordDialogEnter", + "passwordDialogConfirm", + "authenticateToConfigureVault", + "authenticateToUnlockVault", + "vaultBinUsageDialogMessage", "tooManyItemsErrorDialogMessage", "settingsModificationWarningDialogMessage", + "settingsConfirmationVaultDataLoss", "settingsViewerShowDescription", "settingsVideoGestureVerticalDragBrightnessVolume", + "settingsDisablingBinWarningDialogMessage", "settingsAccessibilityShowPinchGestureAlternatives", "settingsDisplayUseTvInterface" ]