#155 #164 viewer: menu review, add copy/move, improved handling nomedia file content uri

This commit is contained in:
Thibault Deckers 2022-02-04 18:27:45 +09:00
parent a1c3399afa
commit 997005c4e5
25 changed files with 424 additions and 307 deletions

View file

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

View file

@ -119,7 +119,7 @@ class ImageByteStreamHandler(private val context: Context, private val arguments
} else if (mimeType == MimeTypes.TIFF) {
TiffImage(context, uri, pageId)
} else {
StorageUtils.getGlideSafeUri(uri, mimeType)
StorageUtils.getGlideSafeUri(context, uri, mimeType)
}
val target = Glide.with(context)

View file

@ -133,7 +133,6 @@ abstract class ImageProvider {
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun exportSingle(
activity: Activity,
sourceEntry: AvesEntry,
@ -174,6 +173,7 @@ abstract class ImageProvider {
targetMimeType = sourceMimeType
write = { output ->
val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri)
@Suppress("BlockingMethodInNonBlockingContext")
sourceDocFile.copyTo(output)
}
} else {
@ -184,7 +184,7 @@ abstract class ImageProvider {
} else if (sourceMimeType == MimeTypes.SVG) {
SvgImage(activity, sourceUri)
} else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
StorageUtils.getGlideSafeUri(activity, sourceUri, sourceMimeType)
}
// request a fresh image with the highest quality format
@ -198,6 +198,7 @@ abstract class ImageProvider {
.apply(glideOptions)
.load(model)
.submit(width, height)
@Suppress("BlockingMethodInNonBlockingContext")
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {
bitmap = BitmapUtils.applyExifOrientation(activity, bitmap, sourceEntry.rotationDegrees, sourceEntry.isFlipped)

View file

@ -329,7 +329,7 @@ object StorageUtils {
// try to strip user info, if any
if (mediaUri.userInfo != null) {
val genericMediaUri = Uri.parse(mediaUri.toString().replaceFirst("${mediaUri.userInfo}@", ""))
val genericMediaUri = stripMediaUriUserInfo(mediaUri)
Log.d(LOG_TAG, "retry getDocumentFile for mediaUri=$mediaUri without userInfo: $genericMediaUri")
return getDocumentFile(context, anyPath, genericMediaUri)
}
@ -442,36 +442,71 @@ object StorageUtils {
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
// to work around a bug from Android Q where metadata redaction corrupts HEIC images.
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
// for some content URIs (e.g. `content://media/external_primary/downloads/...`)
// so we build a typical `images` or `videos` content URI from the original content ID.
fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
// requesting access or writing to some MediaStore content URIs
// e.g. `content://0@media/...`, `content://media/external_primary/downloads/...`
// yields an exception with `All requested items must be referenced by specific ID`
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType)
private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri {
// for some non image/video content URIs (e.g. `downloads`, `file`)
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
// we cannot safely apply this to a file content URI, as it may point to a file not indexed
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI
if (uri.path?.contains("/downloads/") == true) {
uri.tryParseId()?.let { id ->
return when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
val uriPath = uri.path
when {
uriPath?.contains("/downloads/") == true -> {
// e.g. `content://media/external_primary/downloads/...`
getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri }
}
uriPath?.contains("/file/") == true -> {
// e.g. `content://media/external/file/...`
// create an ad-hoc temporary file for decoding only
File.createTempFile("aves", null).apply {
deleteOnExit()
try {
outputStream().use { output ->
openInputStream(context, uri)?.use { input ->
input.copyTo(output)
}
}
return Uri.fromFile(this)
} catch (e: Exception) {
Log.e(LOG_TAG, "failed to create temporary file from uri=$uri", e)
}
}
}
} else if (uri.userInfo != null) {
// strip user info, if any
return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
}
}
return uri
}
// requesting access or writing to some MediaStore content URIs
// yields an exception with `All requested items must be referenced by specific ID`
fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
val uriPath = uri.path
when {
uriPath?.contains("/downloads/") == true -> {
// e.g. `content://media/external_primary/downloads/...`
getMediaUriImageVideoUri(uri, mimeType)?.let { imageVideUri -> return imageVideUri }
}
uri.userInfo != null -> return stripMediaUriUserInfo(uri)
}
}
return uri
}
// Build a typical `images` or `videos` content URI from the original content ID.
// We cannot safely apply this to a `file` content URI, as it may point to a file not indexed
// by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI.
private fun getMediaUriImageVideoUri(uri: Uri, mimeType: String): Uri? {
return uri.tryParseId()?.let { id ->
return when {
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
else -> uri
}
}
}
// strip user info, if any
// e.g. `content://0@media/...`
private fun stripMediaUriUserInfo(uri: Uri) = Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", ""))
fun openInputStream(context: Context, uri: Uri): InputStream? {
val effectiveUri = getOriginalUri(context, uri)
return try {

View file

@ -47,7 +47,6 @@
"entryActionCopyToClipboard": "In die Zwischenablage kopieren",
"entryActionDelete": "Löschen",
"entryActionExport": "Exportieren",
"entryActionInfo": "Info",
"entryActionRename": "Umbenennen",
"entryActionRotateCCW": "Drehen gegen den Uhrzeigersinn",
"entryActionRotateCW": "Drehen im Uhrzeigersinn",
@ -56,10 +55,10 @@
"entryActionShare": "Teilen",
"entryActionViewSource": "Quelle anzeigen",
"entryActionViewMotionPhotoVideo": "Bewegtes Foto öffnen",
"entryActionEdit": "Bearbeiten mit...",
"entryActionOpen": "Öffnen mit...",
"entryActionSetAs": "Einstellen als...",
"entryActionOpenMap": "In der Karten-App anzeigen...",
"entryActionEdit": "Bearbeiten",
"entryActionOpen": "Öffnen mit",
"entryActionSetAs": "Einstellen als",
"entryActionOpenMap": "In der Karten-App anzeigen",
"entryActionRotateScreen": "Bildschirm rotieren",
"entryActionAddFavourite": "Zu Favoriten hinzufügen ",
"entryActionRemoveFavourite": "Aus Favoriten entfernen",

View file

@ -69,8 +69,8 @@
"entryActionCopyToClipboard": "Copy to clipboard",
"entryActionDelete": "Delete",
"entryActionConvert": "Convert",
"entryActionExport": "Export",
"entryActionInfo": "Info",
"entryActionRename": "Rename",
"entryActionRotateCCW": "Rotate counterclockwise",
"entryActionRotateCW": "Rotate clockwise",
@ -79,10 +79,10 @@
"entryActionShare": "Share",
"entryActionViewSource": "View source",
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
"entryActionEdit": "Edit with…",
"entryActionOpen": "Open with",
"entryActionSetAs": "Set as",
"entryActionOpenMap": "Show in map app",
"entryActionEdit": "Edit",
"entryActionOpen": "Open with",
"entryActionSetAs": "Set as",
"entryActionOpenMap": "Show in map app",
"entryActionRotateScreen": "Rotate screen",
"entryActionAddFavourite": "Add to favourites",
"entryActionRemoveFavourite": "Remove from favourites",

View file

@ -47,7 +47,6 @@
"entryActionCopyToClipboard": "Copiar al portapapeles",
"entryActionDelete": "Borrar",
"entryActionExport": "Exportar",
"entryActionInfo": "Información",
"entryActionRename": "Renombrar",
"entryActionRotateCCW": "Rotar en sentido antihorario",
"entryActionRotateCW": "Rotar en sentido horario",
@ -56,10 +55,10 @@
"entryActionShare": "Compartir",
"entryActionViewSource": "Ver fuente",
"entryActionViewMotionPhotoVideo": "Abrir foto en movimiento",
"entryActionEdit": "Editar con…",
"entryActionOpen": "Abrir con",
"entryActionSetAs": "Establecer como",
"entryActionOpenMap": "Mostrar en aplicación de mapa",
"entryActionEdit": "Editar",
"entryActionOpen": "Abrir con",
"entryActionSetAs": "Establecer como",
"entryActionOpenMap": "Mostrar en aplicación de mapa",
"entryActionRotateScreen": "Rotar pantalla",
"entryActionAddFavourite": "Agregar a favoritos",
"entryActionRemoveFavourite": "Quitar de favoritos",

View file

@ -47,7 +47,6 @@
"entryActionCopyToClipboard": "Copier dans presse-papier",
"entryActionDelete": "Supprimer",
"entryActionExport": "Exporter",
"entryActionInfo": "Détails",
"entryActionRename": "Renommer",
"entryActionRotateCCW": "Pivoter à gauche",
"entryActionRotateCW": "Pivoter à droite",
@ -56,10 +55,10 @@
"entryActionShare": "Partager",
"entryActionViewSource": "Voir le code",
"entryActionViewMotionPhotoVideo": "Ouvrir le clip vidéo",
"entryActionEdit": "Modifier avec…",
"entryActionOpen": "Ouvrir avec",
"entryActionSetAs": "Utiliser comme",
"entryActionOpenMap": "Localiser avec",
"entryActionEdit": "Modifier",
"entryActionOpen": "Ouvrir avec",
"entryActionSetAs": "Utiliser comme",
"entryActionOpenMap": "Localiser avec",
"entryActionRotateScreen": "Pivoter lécran",
"entryActionAddFavourite": "Ajouter aux favoris",
"entryActionRemoveFavourite": "Retirer des favoris",

View file

@ -47,7 +47,6 @@
"entryActionCopyToClipboard": "클립보드에 복사",
"entryActionDelete": "삭제",
"entryActionExport": "내보내기",
"entryActionInfo": "상세정보",
"entryActionRename": "이름 변경",
"entryActionRotateCCW": "좌회전",
"entryActionRotateCW": "우회전",
@ -56,10 +55,10 @@
"entryActionShare": "공유",
"entryActionViewSource": "소스 코드 보기",
"entryActionViewMotionPhotoVideo": "모션 포토 보기",
"entryActionEdit": "편집",
"entryActionOpen": "다른 앱에서 열기",
"entryActionSetAs": "다음 용도로 사용",
"entryActionOpenMap": "지도 앱에서 보기",
"entryActionEdit": "편집",
"entryActionOpen": "다른 앱에서 열기",
"entryActionSetAs": "다음 용도로 사용",
"entryActionOpenMap": "지도 앱에서 보기",
"entryActionRotateScreen": "화면 회전",
"entryActionAddFavourite": "즐겨찾기에 추가",
"entryActionRemoveFavourite": "즐겨찾기에서 삭제",

View file

@ -47,7 +47,6 @@
"entryActionCopyToClipboard": "Copiar para área de transferência",
"entryActionDelete": "Excluir",
"entryActionExport": "Exportar",
"entryActionInfo": "Informações",
"entryActionRename": "Renomear",
"entryActionRotateCCW": "Rotacionar para esquerda",
"entryActionRotateCW": "Rotacionar para direita",
@ -56,10 +55,10 @@
"entryActionShare": "Compartilhado",
"entryActionViewSource": "Ver fonte",
"entryActionViewMotionPhotoVideo": "Abrir foto em movimento",
"entryActionEdit": "Editar com…",
"entryActionOpen": "Abrir com",
"entryActionSetAs": "Definir como",
"entryActionOpenMap": "Mostrar no aplicativo de mapa",
"entryActionEdit": "Editar",
"entryActionOpen": "Abrir com",
"entryActionSetAs": "Definir como",
"entryActionOpenMap": "Mostrar no aplicativo de mapa",
"entryActionRotateScreen": "Girar a tela",
"entryActionAddFavourite": "Adicionar aos favoritos",
"entryActionRemoveFavourite": "Remova dos favoritos",

View file

@ -47,7 +47,6 @@
"entryActionCopyToClipboard": "Скопировать в буфер обмена",
"entryActionDelete": "Удалить",
"entryActionExport": "Экспорт",
"entryActionInfo": "Информация",
"entryActionRename": "Переименовать",
"entryActionRotateCCW": "Повернуть против часовой стрелки",
"entryActionRotateCW": "Повернуть по часовой стрелки",
@ -56,10 +55,10 @@
"entryActionShare": "Поделиться",
"entryActionViewSource": "Посмотреть источник",
"entryActionViewMotionPhotoVideo": "Открыть «Живые фото»",
"entryActionEdit": "Изменить с помощью…",
"entryActionOpen": "Открыть с помощью",
"entryActionSetAs": "Установить как",
"entryActionOpenMap": "Показать на карте",
"entryActionEdit": "Изменить",
"entryActionOpen": "Открыть с помощью",
"entryActionSetAs": "Установить как",
"entryActionOpenMap": "Показать на карте",
"entryActionRotateScreen": "Повернуть экран",
"entryActionAddFavourite": "Добавить в избранное",
"entryActionRemoveFavourite": "Удалить из избранного",

View file

@ -7,10 +7,11 @@ enum EntryAction {
addShortcut,
copyToClipboard,
delete,
export,
info,
convert,
print,
rename,
copy,
move,
share,
toggleFavourite,
// raster
@ -31,25 +32,32 @@ enum EntryAction {
}
class EntryActions {
static const inApp = [
EntryAction.info,
EntryAction.toggleFavourite,
static const topLevel = [
EntryAction.share,
EntryAction.delete,
EntryAction.edit,
EntryAction.rename,
EntryAction.export,
EntryAction.addShortcut,
EntryAction.copyToClipboard,
EntryAction.print,
EntryAction.delete,
EntryAction.copy,
EntryAction.move,
EntryAction.toggleFavourite,
EntryAction.viewSource,
EntryAction.rotateScreen,
];
static const externalApp = [
EntryAction.edit,
static const export = [
EntryAction.convert,
EntryAction.addShortcut,
EntryAction.copyToClipboard,
EntryAction.print,
EntryAction.open,
EntryAction.setAs,
EntryAction.openMap,
EntryAction.setAs,
];
static const exportExternal = [
EntryAction.open,
EntryAction.openMap,
EntryAction.setAs,
];
static const pageActions = [
@ -68,14 +76,16 @@ extension ExtraEntryAction on EntryAction {
return context.l10n.entryActionCopyToClipboard;
case EntryAction.delete:
return context.l10n.entryActionDelete;
case EntryAction.export:
return context.l10n.entryActionExport;
case EntryAction.info:
return context.l10n.entryActionInfo;
case EntryAction.convert:
return context.l10n.entryActionConvert;
case EntryAction.print:
return context.l10n.entryActionPrint;
case EntryAction.rename:
return context.l10n.entryActionRename;
case EntryAction.copy:
return context.l10n.collectionActionCopy;
case EntryAction.move:
return context.l10n.collectionActionMove;
case EntryAction.share:
return context.l10n.entryActionShare;
case EntryAction.toggleFavourite:
@ -133,14 +143,16 @@ extension ExtraEntryAction on EntryAction {
return AIcons.clipboard;
case EntryAction.delete:
return AIcons.delete;
case EntryAction.export:
return AIcons.saveAs;
case EntryAction.info:
return AIcons.info;
case EntryAction.convert:
return AIcons.convert;
case EntryAction.print:
return AIcons.print;
case EntryAction.rename:
return AIcons.rename;
case EntryAction.copy:
return AIcons.copy;
case EntryAction.move:
return AIcons.move;
case EntryAction.share:
return AIcons.share;
case EntryAction.toggleFavourite:
@ -158,10 +170,13 @@ extension ExtraEntryAction on EntryAction {
return AIcons.vector;
// external
case EntryAction.edit:
return AIcons.edit;
case EntryAction.open:
return AIcons.openOutside;
case EntryAction.openMap:
return AIcons.map;
case EntryAction.setAs:
return null;
return AIcons.setAs;
// platform
case EntryAction.rotateScreen:
return AIcons.rotateScreen;

View file

@ -50,13 +50,16 @@ class AIcons {
static const IconData captureFrame = Icons.screenshot_outlined;
static const IconData clear = Icons.clear_outlined;
static const IconData clipboard = Icons.content_copy_outlined;
static const IconData convert = Icons.transform_outlined;
static const IconData copy = Icons.file_copy_outlined;
static const IconData debug = Icons.whatshot_outlined;
static const IconData delete = Icons.delete_outlined;
static const IconData edit = Icons.edit_outlined;
static const IconData editRating = MdiIcons.starPlusOutline;
static const IconData editTags = MdiIcons.tagPlusOutline;
static const IconData export = MdiIcons.fileExportOutline;
static const IconData export = Icons.open_with_outlined;
static const IconData fileExport = MdiIcons.fileExportOutline;
static const IconData fileImport = MdiIcons.fileImportOutline;
static const IconData flip = Icons.flip_outlined;
static const IconData favourite = Icons.favorite_border;
static const IconData favouriteActive = Icons.favorite;
@ -65,7 +68,6 @@ class AIcons {
static const IconData geoBounds = Icons.public_outlined;
static const IconData goUp = Icons.arrow_upward_outlined;
static const IconData hide = Icons.visibility_off_outlined;
static const IconData import = MdiIcons.fileImportOutline;
static const IconData info = Icons.info_outlined;
static const IconData layers = Icons.layers_outlined;
static const IconData map = Icons.map_outlined;
@ -83,9 +85,9 @@ class AIcons {
static const IconData rotateLeft = Icons.rotate_left_outlined;
static const IconData rotateRight = Icons.rotate_right_outlined;
static const IconData rotateScreen = Icons.screen_rotation_outlined;
static const IconData saveAs = Icons.save_alt_outlined;
static const IconData search = Icons.search_outlined;
static const IconData select = Icons.select_all_outlined;
static const IconData setAs = Icons.wallpaper_outlined;
static const IconData setCover = MdiIcons.imageEditOutline;
static const IconData share = Icons.share_outlined;
static const IconData show = Icons.visibility_outlined;

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/entry_set_actions.dart';
@ -8,9 +7,7 @@ import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/entry_metadata_edition.dart';
import 'package:aves/model/favourites.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/query.dart';
import 'package:aves/model/selection.dart';
import 'package:aves/model/source/analysis_controller.dart';
@ -18,10 +15,8 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
@ -29,8 +24,6 @@ import 'package:aves/widgets/common/action_mixins/size_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_dialog.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:aves/widgets/map/map_page.dart';
import 'package:aves/widgets/search/search_delegate.dart';
import 'package:aves/widgets/stats/stats_page.dart';
@ -39,7 +32,9 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
import '../common/action_mixins/entry_storage.dart';
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, EntryEditorMixin, EntryStorageMixin {
bool isVisible(
EntrySetAction action, {
required AppMode appMode,
@ -268,7 +263,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<ImageOpEvent>(
await showOpReport<ImageOpEvent>(
context: context,
opStream: mediaFileService.delete(opId: opId, entries: selectedItems),
itemCount: todoCount,
@ -294,132 +289,10 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
}
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
final l10n = context.l10n;
final selection = context.read<Selection<AvesEntry>>();
final selectedItems = _getExpandedSelectedItems(selection);
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final destinationAlbum = await pickAlbum(context: context, moveType: moveType);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return;
// do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection
final todoItems = selectedItems.toSet();
final copy = moveType == MoveType.copy;
final todoCount = todoItems.length;
assert(todoCount > 0);
final destinationDirectory = Directory(destinationAlbum);
final names = [
...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
// do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
];
final uniqueNames = names.toSet();
var nameConflictStrategy = NameConflictStrategy.rename;
if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>(
context: context,
builder: (context) {
return AvesSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
);
},
);
if (value == null) return;
nameConflictStrategy = value;
}
final source = context.read<CollectionSource>();
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.move(
opId: opId,
entries: todoItems,
copy: copy,
destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy,
),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet();
await source.updateAfterMove(
todoEntries: todoItems,
copy: copy,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
selection.browse();
source.resumeMonitoring();
// cleanup
if (moveType == MoveType.move) {
await storageService.deleteEmptyDirectories(selectionDirs);
}
final successCount = successOps.length;
if (successCount < todoCount) {
final count = todoCount - successCount;
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
} else {
final count = movedOps.length;
showFeedback(
context,
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
count > 0
? SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens>();
var targetCollection = collection;
if (collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
// we could simply add the filter to the current collection
// but navigating makes the change less jarring
targetCollection = CollectionLens(
source: collection.source,
filters: collection.filters,
)..addFilter(filter);
unawaited(Navigator.pushReplacement(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: targetCollection,
),
),
));
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
await Future.delayed(delayDuration);
}
await Future.delayed(Durations.highlightScrollInitDelay);
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
}
},
)
: null,
);
}
},
);
await move(context, moveType: moveType, selectedItems: selectedItems);
selection.browse();
}
Future<void> _edit(
@ -439,7 +312,7 @@ class EntrySetActionDelegate with EntryEditorMixin, FeedbackMixin, PermissionAwa
final source = context.read<CollectionSource>();
source.pauseMonitoring();
var cancelled = false;
showOpReport<ImageOpEvent>(
await showOpReport<ImageOpEvent>(
context: context,
opStream: Stream.fromIterable(todoItems).asyncMap((entry) async {
if (cancelled) {

View file

@ -0,0 +1,173 @@
import 'dart:async';
import 'dart:io';
import 'package:aves/app_mode.dart';
import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:aves/theme/durations.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/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
Future<void> move(
BuildContext context, {
required MoveType moveType,
required Set<AvesEntry> selectedItems,
VoidCallback? onSuccess,
}) async {
final source = context.read<CollectionSource>();
if (!source.initialized) {
// source may be uninitialized in viewer mode
await source.init();
unawaited(source.refresh());
}
final l10n = context.l10n;
final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet();
final destinationAlbum = await pickAlbum(context: context, moveType: moveType);
if (destinationAlbum == null) return;
if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return;
if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return;
if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return;
// do not directly use selection when moving and post-processing items
// as source monitoring may remove obsolete items from the original selection
final todoItems = selectedItems.toSet();
final copy = moveType == MoveType.copy;
final todoCount = todoItems.length;
assert(todoCount > 0);
final destinationDirectory = Directory(destinationAlbum);
final names = [
...todoItems.map((v) => '${v.filenameWithoutExtension}${v.extension}'),
// do not guard up front based on directory existence,
// as conflicts could be within moved entries scattered across multiple albums
if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)),
];
final uniqueNames = names.toSet();
var nameConflictStrategy = NameConflictStrategy.rename;
if (uniqueNames.length < names.length) {
final value = await showDialog<NameConflictStrategy>(
context: context,
builder: (context) {
return AvesSelectionDialog<NameConflictStrategy>(
initialValue: nameConflictStrategy,
options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))),
message: selectionDirs.length == 1 ? l10n.nameConflictDialogSingleSourceMessage : l10n.nameConflictDialogMultipleSourceMessage,
confirmationButtonLabel: l10n.continueButtonLabel,
);
},
);
if (value == null) return;
nameConflictStrategy = value;
}
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
await showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.move(
opId: opId,
entries: todoItems,
copy: copy,
destinationAlbum: destinationAlbum,
nameConflictStrategy: nameConflictStrategy,
),
itemCount: todoCount,
onCancel: () => mediaFileService.cancelFileOp(opId),
onDone: (processed) async {
final successOps = processed.where((e) => e.success).toSet();
final movedOps = successOps.where((e) => !e.skipped).toSet();
await source.updateAfterMove(
todoEntries: todoItems,
copy: copy,
destinationAlbum: destinationAlbum,
movedOps: movedOps,
);
source.resumeMonitoring();
// cleanup
if (moveType == MoveType.move) {
await storageService.deleteEmptyDirectories(selectionDirs);
}
final successCount = successOps.length;
if (successCount < todoCount) {
final count = todoCount - successCount;
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
} else {
final count = movedOps.length;
final appMode = context.read<ValueNotifier<AppMode>>().value;
SnackBarAction? action;
if (count > 0 && appMode == AppMode.main) {
action = SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () async {
late CollectionLens targetCollection;
final highlightInfo = context.read<HighlightInfo>();
final collection = context.read<CollectionLens?>();
if (collection != null) {
targetCollection = collection;
}
if (collection == null || collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
// we could simply add the filter to the current collection
// but navigating makes the change less jarring
targetCollection = CollectionLens(
source: source,
filters: collection?.filters,
)..addFilter(filter);
unawaited(Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
collection: targetCollection,
),
),
(route) => false,
));
final delayDuration = context.read<DurationsData>().staggeredAnimationPageTarget;
await Future.delayed(delayDuration);
}
await Future.delayed(Durations.highlightScrollInitDelay);
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
}
},
);
}
showFeedback(
context,
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
action,
);
onSuccess?.call();
}
},
);
}
}

View file

@ -69,14 +69,14 @@ mixin FeedbackMixin {
// report overlay for multiple operations
void showOpReport<T>({
Future<void> showOpReport<T>({
required BuildContext context,
required Stream<T> opStream,
required int itemCount,
VoidCallback? onCancel,
void Function(Set<T> processed)? onDone,
}) {
showDialog(
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => ReportOverlay<T>(

View file

@ -52,7 +52,7 @@ class PopupMenuItemExpansionPanel<T> extends StatefulWidget {
final bool enabled;
final IconData icon;
final String title;
final List<PopupMenuItem<T>> items;
final List<PopupMenuEntry<T>> items;
const PopupMenuItemExpansionPanel({
Key? key,

View file

@ -217,7 +217,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<ImageOpEvent>(
await showOpReport<ImageOpEvent>(
context: context,
opStream: mediaFileService.delete(opId: opId, entries: todoEntries),
itemCount: todoCount,
@ -281,7 +281,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> {
source.pauseMonitoring();
final opId = mediaFileService.newOpId;
showOpReport<MoveOpEvent>(
await showOpReport<MoveOpEvent>(
context: context,
opStream: mediaFileService.move(
opId: opId,

View file

@ -54,11 +54,11 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
return [
PopupMenuItem(
value: SettingsAction.export,
child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.export)),
child: MenuRow(text: context.l10n.settingsActionExport, icon: const Icon(AIcons.fileExport)),
),
PopupMenuItem(
value: SettingsAction.import,
child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.import)),
child: MenuRow(text: context.l10n.settingsActionImport, icon: const Icon(AIcons.fileImport)),
),
];
},

View file

@ -30,16 +30,7 @@ class ViewerActionEditorPage extends StatelessWidget {
const ViewerActionEditorPage({Key? key}) : super(key: key);
static const allAvailableActions = [
EntryAction.info,
EntryAction.toggleFavourite,
EntryAction.share,
EntryAction.delete,
EntryAction.rename,
EntryAction.export,
EntryAction.addShortcut,
EntryAction.copyToClipboard,
EntryAction.print,
EntryAction.rotateScreen,
...EntryActions.topLevel,
EntryAction.rotateCCW,
EntryAction.rotateCW,
EntryAction.flip,

View file

@ -17,6 +17,7 @@ import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/collection_page.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';
@ -36,7 +37,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:tuple/tuple.dart';
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin {
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin {
@override
final AvesEntry entry;
@ -55,11 +56,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.delete:
_delete(context);
break;
case EntryAction.export:
_export(context);
break;
case EntryAction.info:
ShowInfoNotification().dispatch(context);
case EntryAction.convert:
_convert(context);
break;
case EntryAction.print:
EntryPrinter(entry).print(context);
@ -67,6 +65,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
case EntryAction.rename:
_rename(context);
break;
case EntryAction.copy:
_move(context, moveType: MoveType.copy);
break;
case EntryAction.move:
_move(context, moveType: MoveType.move);
break;
case EntryAction.share:
androidAppService.shareEntries({entry}).then((success) {
if (!success) showNoMatchingAppDialog(context);
@ -188,11 +192,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (source.initialized) {
await source.removeEntries({entry.uri});
}
EntryDeletedNotification(entry).dispatch(context);
EntryRemovedNotification(entry).dispatch(context);
}
}
Future<void> _export(BuildContext context) async {
Future<void> _convert(BuildContext context) async {
final options = await showDialog<EntryExportOptions>(
context: context,
builder: (context) => ExportEntryDialog(entry: entry),
);
if (options == null) return;
final source = context.read<CollectionSource>();
if (!source.initialized) {
await source.init();
@ -204,12 +214,6 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return;
final options = await showDialog<EntryExportOptions>(
context: context,
builder: (context) => ExportEntryDialog(entry: entry),
);
if (options == null) return;
final selection = <AvesEntry>{};
if (entry.isMultiPage) {
final multiPageInfo = await entry.getMultiPageInfo();
@ -227,9 +231,8 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final selectionCount = selection.length;
source.pauseMonitoring();
showOpReport<ExportOpEvent>(
await showOpReport<ExportOpEvent>(
context: context,
// TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures)
opStream: mediaFileService.export(
selection,
options: options,
@ -293,6 +296,15 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
);
}
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
await move(
context,
moveType: moveType,
selectedItems: {entry},
onSuccess: moveType == MoveType.move ? () => EntryRemovedNotification(entry).dispatch(context) : null,
);
}
Future<void> _rename(BuildContext context) async {
final newName = await showDialog<String>(
context: context,

View file

@ -195,8 +195,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
onNotification: (dynamic notification) {
if (notification is FilterSelectedNotification) {
_goToCollection(notification.filter);
} else if (notification is EntryDeletedNotification) {
_onEntryDeleted(context, notification.entry);
} else if (notification is EntryRemovedNotification) {
_onEntryRemoved(context, notification.entry);
}
return false;
},
@ -453,7 +453,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with FeedbackMixin,
_updateEntry();
}
void _onEntryDeleted(BuildContext context, AvesEntry entry) {
void _onEntryRemoved(BuildContext context, AvesEntry entry) {
// deleted or moved to another album
if (hasCollection) {
final entries = collection!.sortedEntries;
entries.remove(entry);

View file

@ -12,8 +12,9 @@ class FilterSelectedNotification extends Notification {
const FilterSelectedNotification(this.filter);
}
class EntryDeletedNotification extends Notification {
// deleted or moved to another album
class EntryRemovedNotification extends Notification {
final AvesEntry entry;
const EntryDeletedNotification(this.entry);
const EntryRemovedNotification(this.entry);
}

View file

@ -3,8 +3,10 @@ import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/basic/menu.dart';
import 'package:aves/widgets/common/basic/popup_menu_button.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/favourite_toggler.dart';
import 'package:aves/widgets/viewer/action/entry_action_delegate.dart';
import 'package:aves/widgets/viewer/multipage/conductor.dart';
@ -13,6 +15,7 @@ import 'package:aves/widgets/viewer/overlay/minimap.dart';
import 'package:aves/widgets/viewer/overlay/notifications.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
@ -70,12 +73,14 @@ class ViewerTopOverlay extends StatelessWidget {
return canToggleFavourite;
case EntryAction.delete:
case EntryAction.rename:
case EntryAction.copy:
case EntryAction.move:
return targetEntry.canEdit;
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.flip:
return targetEntry.canRotateAndFlip;
case EntryAction.export:
case EntryAction.convert:
case EntryAction.print:
return !targetEntry.isVideo && device.canPrint;
case EntryAction.openMap:
@ -88,7 +93,6 @@ class ViewerTopOverlay extends StatelessWidget {
return device.canPinShortcut;
case EntryAction.copyToClipboard:
case EntryAction.edit:
case EntryAction.info:
case EntryAction.open:
case EntryAction.setAs:
case EntryAction.share:
@ -102,12 +106,12 @@ class ViewerTopOverlay extends StatelessWidget {
selector: (context, s) => s.isRotationLocked,
builder: (context, s, child) {
final quickActions = settings.viewerQuickActions.where(_isVisible).take(availableCount - 1).toList();
final inAppActions = EntryActions.inApp.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
final externalAppActions = EntryActions.externalApp.where(_isVisible).toList();
final topLevelActions = EntryActions.topLevel.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
final exportActions = EntryActions.export.where((action) => !quickActions.contains(action)).where(_isVisible).toList();
return _TopOverlayRow(
quickActions: quickActions,
inAppActions: inAppActions,
externalAppActions: externalAppActions,
topLevelActions: topLevelActions,
exportActions: exportActions,
scale: scale,
mainEntry: mainEntry,
pageEntry: pageEntry!,
@ -138,7 +142,7 @@ class ViewerTopOverlay extends StatelessWidget {
}
class _TopOverlayRow extends StatelessWidget {
final List<EntryAction> quickActions, inAppActions, externalAppActions;
final List<EntryAction> quickActions, topLevelActions, exportActions;
final Animation<double> scale;
final AvesEntry mainEntry, pageEntry;
@ -147,8 +151,8 @@ class _TopOverlayRow extends StatelessWidget {
const _TopOverlayRow({
Key? key,
required this.quickActions,
required this.inAppActions,
required this.externalAppActions,
required this.topLevelActions,
required this.exportActions,
required this.scale,
required this.mainEntry,
required this.pageEntry,
@ -169,16 +173,30 @@ class _TopOverlayRow extends StatelessWidget {
child: MenuIconTheme(
child: AvesPopupMenuButton<EntryAction>(
key: const Key('entry-menu-button'),
itemBuilder: (context) => [
...inAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
const PopupMenuDivider(),
...externalAppActions.map((action) => _buildPopupMenuItem(context, action)),
if (!kReleaseMode) ...[
const PopupMenuDivider(),
_buildPopupMenuItem(context, EntryAction.debug),
]
],
itemBuilder: (context) {
final exportInternalActions = exportActions.whereNot(EntryActions.exportExternal.contains).toList();
final exportExternalActions = exportActions.where(EntryActions.exportExternal.contains).toList();
return [
if (pageEntry.canRotateAndFlip) _buildRotateAndFlipMenuItems(context),
...topLevelActions.map((action) => _buildPopupMenuItem(context, action)),
PopupMenuItem<EntryAction>(
padding: EdgeInsets.zero,
child: PopupMenuItemExpansionPanel<EntryAction>(
icon: AIcons.export,
title: context.l10n.entryActionExport,
items: [
...exportInternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
if (exportInternalActions.isNotEmpty && exportExternalActions.isNotEmpty) const PopupMenuDivider(height: 0),
...exportExternalActions.map((action) => _buildPopupMenuItem(context, action)).toList(),
],
),
),
if (!kReleaseMode) ...[
const PopupMenuDivider(),
_buildPopupMenuItem(context, EntryAction.debug),
]
];
},
onSelected: (action) {
// wait for the popup menu to hide before proceeding with the action
Future.delayed(Durations.popupMenuAnimation * timeDilation, () => _onActionSelected(context, action));
@ -206,44 +224,24 @@ class _TopOverlayRow extends StatelessWidget {
onPressed: onPressed,
);
break;
case EntryAction.addShortcut:
case EntryAction.copyToClipboard:
case EntryAction.delete:
case EntryAction.export:
case EntryAction.flip:
case EntryAction.info:
case EntryAction.print:
case EntryAction.rename:
case EntryAction.rotateCCW:
case EntryAction.rotateCW:
case EntryAction.share:
case EntryAction.rotateScreen:
case EntryAction.viewSource:
default:
child = IconButton(
icon: action.getIcon() ?? const SizedBox(),
onPressed: onPressed,
tooltip: action.getText(context),
);
break;
case EntryAction.openMap:
case EntryAction.open:
case EntryAction.edit:
case EntryAction.setAs:
case EntryAction.debug:
break;
}
return child != null
? Padding(
padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding),
child: OverlayButton(
scale: scale,
child: child,
),
)
: const SizedBox.shrink();
return Padding(
padding: const EdgeInsetsDirectional.only(end: ViewerTopOverlay.innerPadding),
child: OverlayButton(
scale: scale,
child: child,
),
);
}
PopupMenuEntry<EntryAction> _buildPopupMenuItem(BuildContext context, EntryAction action) {
PopupMenuItem<EntryAction> _buildPopupMenuItem(BuildContext context, EntryAction action) {
Widget? child;
switch (action) {
// in app actions

View file

@ -1,5 +1,10 @@
{
"de": [
"entryActionConvert"
],
"es": [
"entryActionConvert",
"entryInfoActionEditLocation",
"exportEntryDialogWidth",
"exportEntryDialogHeight",
@ -8,5 +13,21 @@
"editEntryLocationDialogLatitude",
"editEntryLocationDialogLongitude",
"locationPickerUseThisLocationButton"
],
"fr": [
"entryActionConvert"
],
"ko": [
"entryActionConvert"
],
"pt": [
"entryActionConvert"
],
"ru": [
"entryActionConvert"
]
}