diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d427e653..10c443505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added - Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb +- Albums: localized common album names ### Changed - Upgraded Flutter to beta v2.1.0-12.2.pre 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 507ac09d9..9710f8962 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 @@ -6,7 +6,6 @@ import android.net.Uri import android.os.Build import android.os.Environment import android.os.storage.StorageManager -import android.util.Log import androidx.core.os.EnvironmentCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.utils.PermissionManager @@ -150,7 +149,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler { try { val dir = File(it) if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) { - Log.d("TLAD", "deleted empty directory=$dir") deleted++ } } catch (e: SecurityException) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a245d94a4..d921c17ae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -450,6 +450,15 @@ "albumPickPageTitleMove": "Move to Album", "@albumPickPageTitleMove": {}, + "albumCamera": "Camera", + "@albumCamera": {}, + "albumDownload": "Download", + "@albumDownload": {}, + "albumScreenshots": "Screenshots", + "@albumScreenshots": {}, + "albumScreenRecordings": "Screen recordings", + "@albumScreenRecordings": {}, + "albumPageTitle": "Albums", "@albumPageTitle": {}, "albumEmpty": "No albums", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index eca076e12..d9dd708cd 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -201,6 +201,11 @@ "albumPickPageTitleExport": "앨범으로 내보내기", "albumPickPageTitleMove": "앨범으로 이동", + "albumCamera": "카메라", + "albumDownload": "다운로드", + "albumScreenshots": "스크린샷", + "albumScreenRecordings": "화면 녹화 파일", + "albumPageTitle": "앨범", "albumEmpty": "앨범이 없습니다", "createAlbumTooltip": "새 앨범 만들기", diff --git a/lib/model/entry.dart b/lib/model/entry.dart index db0614254..5f0cb0f0d 100644 --- a/lib/model/entry.dart +++ b/lib/model/entry.dart @@ -18,7 +18,6 @@ import 'package:country_code/country_code.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:latlong/latlong.dart'; -import 'package:path/path.dart' as ppath; import '../ref/mime_types.dart'; @@ -186,17 +185,17 @@ class AvesEntry { String get path => _path; String get directory { - _directory ??= path != null ? ppath.dirname(path) : null; + _directory ??= path != null ? pContext.dirname(path) : null; return _directory; } String get filenameWithoutExtension { - _filename ??= path != null ? ppath.basenameWithoutExtension(path) : null; + _filename ??= path != null ? pContext.basenameWithoutExtension(path) : null; return _filename; } String get extension { - _extension ??= path != null ? ppath.extension(path) : null; + _extension ??= path != null ? pContext.extension(path) : null; return _extension; } diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index a9b21613e..edab0e9bc 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -1,12 +1,12 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:path/path.dart'; class AlbumFilter extends CollectionFilter { static const type = 'album'; @@ -14,9 +14,9 @@ class AlbumFilter extends CollectionFilter { static final Map _appColors = {}; final String album; - final String uniqueName; + final String displayName; - const AlbumFilter(this.album, this.uniqueName); + const AlbumFilter(this.album, this.displayName); AlbumFilter.fromMap(Map json) : this( @@ -28,14 +28,14 @@ class AlbumFilter extends CollectionFilter { Map toMap() => { 'type': type, 'album': album, - 'uniqueName': uniqueName, + 'uniqueName': displayName, }; @override EntryFilter get test => (entry) => entry.directory == album; @override - String get universalLabel => uniqueName ?? album.split(separator).last; + String get universalLabel => displayName ?? pContext.split(album).last; @override String getTooltip(BuildContext context) => album; diff --git a/lib/model/metadata_db.dart b/lib/model/metadata_db.dart index 561978da0..1e1474325 100644 --- a/lib/model/metadata_db.dart +++ b/lib/model/metadata_db.dart @@ -5,8 +5,8 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/metadata.dart'; import 'package:aves/model/metadata_db_upgrade.dart'; +import 'package:aves/services/services.dart'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; abstract class MetadataDb { @@ -82,7 +82,7 @@ abstract class MetadataDb { class SqfliteMetadataDb implements MetadataDb { Future _database; - Future get path async => join(await getDatabasesPath(), 'metadata.db'); + Future get path async => pContext.join(await getDatabasesPath(), 'metadata.db'); static const entryTable = 'entry'; static const dateTakenTable = 'dateTaken'; diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 699aaa54c..cbb8d1305 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -2,10 +2,11 @@ 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/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -import 'package:path/path.dart'; mixin AlbumMixin on SourceBase { final Set _directories = {}; @@ -13,8 +14,8 @@ mixin AlbumMixin on SourceBase { List get rawAlbums => List.unmodifiable(_directories); int compareAlbumsByName(String a, String b) { - final ua = getUniqueAlbumName(null, a); - final ub = getUniqueAlbumName(null, b); + final ua = getAlbumDisplayName(null, a); + final ub = getAlbumDisplayName(null, b); final c = compareAsciiUpperCase(ua, ub); if (c != 0) return c; final va = androidFileUtils.getStorageVolume(a)?.path ?? ''; @@ -24,36 +25,50 @@ mixin AlbumMixin on SourceBase { void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent()); - String getUniqueAlbumName(BuildContext context, String dirPath) { - String unique(String dirPath, [bool Function(String) test]) { - final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath); - final parts = dirPath.split(separator); - var partCount = 0; - String testName; - do { - testName = separator + parts.skip(parts.length - ++partCount).join(separator); - } while (otherAlbums.any((item) => item.endsWith(testName))); - final uniqueName = parts.skip(parts.length - partCount).join(separator); - return uniqueName; + String getAlbumDisplayName(BuildContext context, String dirPath) { + assert(!dirPath.endsWith(pContext.separator)); + + if (context != null) { + final type = androidFileUtils.getAlbumType(dirPath); + if (type == AlbumType.camera) return context.l10n.albumCamera; + if (type == AlbumType.download) return context.l10n.albumDownload; + if (type == AlbumType.screenshots) return context.l10n.albumScreenshots; + if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings; } final dir = VolumeRelativeDirectory.fromPath(dirPath); if (dir == null) return dirPath; - final uniqueNameInDevice = unique(dirPath); final relativeDir = dir.relativeDir; - if (relativeDir.isEmpty) return uniqueNameInDevice; + if (relativeDir.isEmpty) { + final volume = androidFileUtils.getStorageVolume(dirPath); + return volume.getDescription(context); + } + String unique(String dirPath, Set others) { + final parts = pContext.split(dirPath); + for (var i = parts.length - 1; i > 0; i--) { + final testName = pContext.joinAll(['', ...parts.skip(i)]); + if (others.every((item) => !item.endsWith(testName))) return testName; + } + return dirPath; + } + + final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet(); + final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice); if (uniqueNameInDevice.length < relativeDir.length) { return uniqueNameInDevice; + } + + final volumePath = dir.volumePath; + String trimVolumePath(String path) => path.substring(dir.volumePath.length); + final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet(); + final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume); + final volume = androidFileUtils.getStorageVolume(dirPath); + if (volume.isPrimary) { + return uniqueNameInVolume; } else { - final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath)); - final volume = androidFileUtils.getStorageVolume(dirPath); - if (volume.isPrimary) { - return uniqueNameInVolume; - } else { - return '$uniqueNameInVolume (${volume.getDescription(context)})'; - } + return '$uniqueNameInVolume (${volume.getDescription(context)})'; } } diff --git a/lib/services/services.dart b/lib/services/services.dart index e817e4cfa..bf1ebc7ee 100644 --- a/lib/services/services.dart +++ b/lib/services/services.dart @@ -3,25 +3,31 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/services/time_service.dart'; import 'package:get_it/get_it.dart'; +import 'package:path/path.dart' as p; final getIt = GetIt.instance; +final pContext = getIt(); final availability = getIt(); final metadataDb = getIt(); final imageFileService = getIt(); final mediaStoreService = getIt(); final metadataService = getIt(); +final storageService = getIt(); final timeService = getIt(); void initPlatformServices() { + getIt.registerLazySingleton(() => p.Context()); getIt.registerLazySingleton(() => LiveAvesAvailability()); getIt.registerLazySingleton(() => SqfliteMetadataDb()); getIt.registerLazySingleton(() => PlatformImageFileService()); getIt.registerLazySingleton(() => PlatformMediaStoreService()); getIt.registerLazySingleton(() => PlatformMetadataService()); + getIt.registerLazySingleton(() => PlatformStorageService()); getIt.registerLazySingleton(() => PlatformTimeService()); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 071ecdf20..80809a23a 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -5,11 +5,35 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:streams_channel/streams_channel.dart'; -class StorageService { +abstract class StorageService { + Future> getStorageVolumes(); + + Future getFreeSpace(StorageVolume volume); + + Future> getGrantedDirectories(); + + Future revokeDirectoryAccess(String path); + + Future> getInaccessibleDirectories(Iterable dirPaths); + + Future> getRestrictedDirectories(); + + // returns whether user granted access to volume root at `volumePath` + Future requestVolumeAccess(String volumePath); + + // returns number of deleted directories + Future deleteEmptyDirectories(Iterable dirPaths); + + // returns media URI + Future scanFile(String path, String mimeType); +} + +class PlatformStorageService implements StorageService { static const platform = MethodChannel('deckers.thibault/aves/storage'); static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream'); - static Future> getStorageVolumes() async { + @override + Future> getStorageVolumes() async { try { final result = await platform.invokeMethod('getStorageVolumes'); return (result as List).cast().map((map) => StorageVolume.fromMap(map)).toSet(); @@ -19,7 +43,8 @@ class StorageService { return {}; } - static Future getFreeSpace(StorageVolume volume) async { + @override + Future getFreeSpace(StorageVolume volume) async { try { final result = await platform.invokeMethod('getFreeSpace', { 'path': volume.path, @@ -31,7 +56,8 @@ class StorageService { return 0; } - static Future> getGrantedDirectories() async { + @override + Future> getGrantedDirectories() async { try { final result = await platform.invokeMethod('getGrantedDirectories'); return (result as List).cast(); @@ -41,7 +67,8 @@ class StorageService { return []; } - static Future revokeDirectoryAccess(String path) async { + @override + Future revokeDirectoryAccess(String path) async { try { await platform.invokeMethod('revokeDirectoryAccess', { 'path': path, @@ -52,7 +79,8 @@ class StorageService { return; } - static Future> getInaccessibleDirectories(Iterable dirPaths) async { + @override + Future> getInaccessibleDirectories(Iterable dirPaths) async { try { final result = await platform.invokeMethod('getInaccessibleDirectories', { 'dirPaths': dirPaths.toList(), @@ -64,7 +92,8 @@ class StorageService { return null; } - static Future> getRestrictedDirectories() async { + @override + Future> getRestrictedDirectories() async { try { final result = await platform.invokeMethod('getRestrictedDirectories'); return (result as List).cast().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet(); @@ -75,7 +104,8 @@ class StorageService { } // returns whether user granted access to volume root at `volumePath` - static Future requestVolumeAccess(String volumePath) async { + @override + Future requestVolumeAccess(String volumePath) async { try { final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ @@ -96,7 +126,8 @@ class StorageService { } // returns number of deleted directories - static Future deleteEmptyDirectories(Iterable dirPaths) async { + @override + Future deleteEmptyDirectories(Iterable dirPaths) async { try { return await platform.invokeMethod('deleteEmptyDirectories', { 'dirPaths': dirPaths.toList(), @@ -108,7 +139,8 @@ class StorageService { } // returns media URI - static Future scanFile(String path, String mimeType) async { + @override + Future scanFile(String path, String mimeType) async { debugPrint('scanFile with path=$path, mimeType=$mimeType'); try { final uriString = await platform.invokeMethod('scanFile', { diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index d24d433b8..85fb14eca 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,10 +1,9 @@ import 'package:aves/services/android_app_service.dart'; -import 'package:aves/services/storage_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; -import 'package:path/path.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); @@ -21,13 +20,13 @@ class AndroidFileUtils { AndroidFileUtils._private(); Future init() async { - storageVolumes = await StorageService.getStorageVolumes(); + storageVolumes = await storageService.getStorageVolumes(); // path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files' primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path; - dcimPath = join(primaryStorage, 'DCIM'); - downloadPath = join(primaryStorage, 'Download'); - moviesPath = join(primaryStorage, 'Movies'); - picturesPath = join(primaryStorage, 'Pictures'); + dcimPath = pContext.join(primaryStorage, 'DCIM'); + downloadPath = pContext.join(primaryStorage, 'Download'); + moviesPath = pContext.join(primaryStorage, 'Movies'); + picturesPath = pContext.join(primaryStorage, 'Pictures'); } Future initAppNames() async { @@ -60,7 +59,7 @@ class AndroidFileUtils { if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings; if (isScreenshotsPath(albumPath)) return AlbumType.screenshots; - final dir = albumPath.split(separator).last; + final dir = pContext.split(albumPath).last; if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app; } return AlbumType.regular; @@ -68,7 +67,7 @@ class AndroidFileUtils { String getAlbumAppPackageName(String albumPath) { if (albumPath == null) return null; - final dir = albumPath.split(separator).last; + final dir = pContext.split(albumPath).last; final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null); return package?.packageName; } diff --git a/lib/widgets/collection/draggable_thumb_label.dart b/lib/widgets/collection/draggable_thumb_label.dart index df13fb0a2..8706e10be 100644 --- a/lib/widgets/collection/draggable_thumb_label.dart +++ b/lib/widgets/collection/draggable_thumb_label.dart @@ -28,7 +28,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget { case EntryGroupFactor.album: return [ DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate), - if (_hasMultipleSections(context)) context.read().getUniqueAlbumName(context, entry.directory), + if (_hasMultipleSections(context)) context.read().getAlbumDisplayName(context, entry.directory), ]; case EntryGroupFactor.month: case EntryGroupFactor.none: @@ -43,7 +43,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget { break; case EntrySortFactor.name: return [ - if (_hasMultipleSections(context)) context.read().getUniqueAlbumName(context, entry.directory), + if (_hasMultipleSections(context)) context.read().getAlbumDisplayName(context, entry.directory), entry.bestTitle, ]; case EntrySortFactor.size: diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 59d41a721..82e111941 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -9,7 +9,6 @@ import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; -import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -69,7 +68,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (moveType == MoveType.move) { // check whether moving is possible given OS restrictions, // before asking to pick a destination album - final restrictedDirs = await StorageService.getRestrictedDirectories(); + final restrictedDirs = await storageService.getRestrictedDirectories(); for (final selectionDir in selectionDirs) { final dir = VolumeRelativeDirectory.fromPath(selectionDir); if (restrictedDirs.contains(dir)) { @@ -127,7 +126,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware // cleanup if (moveType == MoveType.move) { - await StorageService.deleteEmptyDirectories(selectionDirs); + await storageService.deleteEmptyDirectories(selectionDirs); } }, ); @@ -178,7 +177,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware } // cleanup - await StorageService.deleteEmptyDirectories(selectionDirs); + await storageService.deleteEmptyDirectories(selectionDirs); }, ); } diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index 5dcfe766c..b2c5f3f93 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -46,7 +46,7 @@ class AlbumSectionHeader extends StatelessWidget { return SectionHeader.getPreferredHeight( context: context, maxWidth: maxWidth, - title: source.getUniqueAlbumName(context, directory), + title: source.getAlbumDisplayName(context, directory), hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular, hasTrailing: androidFileUtils.isOnRemovableStorage(directory), ); diff --git a/lib/widgets/collection/grid/headers/any.dart b/lib/widgets/collection/grid/headers/any.dart index e323ba087..56f992c03 100644 --- a/lib/widgets/collection/grid/headers/any.dart +++ b/lib/widgets/collection/grid/headers/any.dart @@ -60,7 +60,7 @@ class CollectionSectionHeader extends StatelessWidget { return AlbumSectionHeader( key: ValueKey(sectionKey), directory: directory, - albumName: source.getUniqueAlbumName(context, directory), + albumName: source.getAlbumDisplayName(context, directory), ); } diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index b0da008bf..7c2d91955 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -1,5 +1,5 @@ import 'package:aves/model/entry.dart'; -import 'package:aves/services/storage_service.dart'; +import 'package:aves/services/services.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'; @@ -11,9 +11,9 @@ mixin PermissionAwareMixin { } Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths) async { - final restrictedDirs = await StorageService.getRestrictedDirectories(); + final restrictedDirs = await storageService.getRestrictedDirectories(); while (true) { - final dirs = await StorageService.getInaccessibleDirectories(albumPaths); + final dirs = await storageService.getInaccessibleDirectories(albumPaths); if (dirs == null) return false; if (dirs.isEmpty) return true; @@ -49,7 +49,7 @@ mixin PermissionAwareMixin { // abort if the user cancels in Flutter if (confirmed == null || !confirmed) return false; - final granted = await StorageService.requestVolumeAccess(dir.volumePath); + final granted = await storageService.requestVolumeAccess(dir.volumePath); if (!granted) { // abort if the user denies access from the native dialog return false; diff --git a/lib/widgets/common/action_mixins/size_aware.dart b/lib/widgets/common/action_mixins/size_aware.dart index ef392018d..95b0e338a 100644 --- a/lib/widgets/common/action_mixins/size_aware.dart +++ b/lib/widgets/common/action_mixins/size_aware.dart @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/entry.dart'; -import 'package:aves/services/storage_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -20,7 +20,7 @@ mixin SizeAwareMixin { MoveType moveType, ) async { final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum); - final free = await StorageService.getFreeSpace(destinationVolume); + final free = await storageService.getFreeSpace(destinationVolume); int needed; int sumSize(sum, entry) => sum + entry.sizeBytes; switch (moveType) { diff --git a/lib/widgets/common/grid/draggable_thumb_label.dart b/lib/widgets/common/grid/draggable_thumb_label.dart index 515039299..fa84ebb5a 100644 --- a/lib/widgets/common/grid/draggable_thumb_label.dart +++ b/lib/widgets/common/grid/draggable_thumb_label.dart @@ -17,7 +17,7 @@ class DraggableThumbLabel extends StatelessWidget { Widget build(BuildContext context) { final sll = context.read>(); final sectionLayout = sll.getSectionAt(offsetY); - if (sectionLayout == null) return null; + if (sectionLayout == null) return SizedBox(); final section = sll.sections[sectionLayout.sectionKey]; final dy = offsetY - (sectionLayout.minOffset + sectionLayout.headerExtent); diff --git a/lib/widgets/debug/storage.dart b/lib/widgets/debug/storage.dart index a1fbbae69..9272ecfaa 100644 --- a/lib/widgets/debug/storage.dart +++ b/lib/widgets/debug/storage.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/storage_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; @@ -17,7 +17,7 @@ class _DebugStorageSectionState extends State with Automati void initState() { super.initState(); androidFileUtils.storageVolumes.forEach((volume) async { - final byteCount = await StorageService.getFreeSpace(volume); + final byteCount = await storageService.getFreeSpace(volume); setState(() => _freeSpaceByVolume[volume.path] = byteCount); }); } diff --git a/lib/widgets/dialogs/create_album_dialog.dart b/lib/widgets/dialogs/create_album_dialog.dart index afc19ca6f..8b1852247 100644 --- a/lib/widgets/dialogs/create_album_dialog.dart +++ b/lib/widgets/dialogs/create_album_dialog.dart @@ -1,12 +1,12 @@ import 'dart:io'; +import 'package:aves/services/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:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:path/path.dart'; import 'aves_dialog.dart'; @@ -143,7 +143,7 @@ class _CreateAlbumDialogState extends State { String _buildAlbumPath(String name) { if (name == null || name.isEmpty) return ''; - return join(_selectedVolume.path, 'Pictures', name); + return pContext.join(_selectedVolume.path, 'Pictures', name); } Future _validate() async { diff --git a/lib/widgets/dialogs/rename_album_dialog.dart b/lib/widgets/dialogs/rename_album_dialog.dart index 6d16f74fb..07ee1a9e2 100644 --- a/lib/widgets/dialogs/rename_album_dialog.dart +++ b/lib/widgets/dialogs/rename_album_dialog.dart @@ -1,8 +1,8 @@ import 'dart:io'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as path; import '../dialogs/aves_dialog.dart'; @@ -22,7 +22,7 @@ class _RenameAlbumDialogState extends State { String get album => widget.album; - String get initialValue => path.basename(album); + String get initialValue => pContext.basename(album); @override void initState() { @@ -75,7 +75,7 @@ class _RenameAlbumDialogState extends State { String _buildAlbumPath(String name) { if (name == null || name.isEmpty) return ''; - return path.join(path.dirname(album), name); + return pContext.join(pContext.dirname(album), name); } Future _validate() async { diff --git a/lib/widgets/dialogs/rename_entry_dialog.dart b/lib/widgets/dialogs/rename_entry_dialog.dart index c72028759..a7c25692c 100644 --- a/lib/widgets/dialogs/rename_entry_dialog.dart +++ b/lib/widgets/dialogs/rename_entry_dialog.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'package:aves/model/entry.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as path; import 'aves_dialog.dart'; @@ -69,7 +69,7 @@ class _RenameEntryDialogState extends State { String _buildEntryPath(String name) { if (name == null || name.isEmpty) return ''; - return path.join(entry.directory, name + entry.extension); + return pContext.join(entry.directory, name + entry.extension); } Future _validate() async { diff --git a/lib/widgets/drawer/album_tile.dart b/lib/widgets/drawer/album_tile.dart index 6bb8b2199..0c2706846 100644 --- a/lib/widgets/drawer/album_tile.dart +++ b/lib/widgets/drawer/album_tile.dart @@ -15,13 +15,13 @@ class AlbumTile extends StatelessWidget { @override Widget build(BuildContext context) { final source = context.read(); - final uniqueName = source.getUniqueAlbumName(context, album); + final displayName = source.getAlbumDisplayName(context, album); return CollectionNavTile( leading: IconUtils.getAlbumIcon( context: context, album: album, ), - title: uniqueName, + title: displayName, trailing: androidFileUtils.isOnRemovableStorage(album) ? Icon( AIcons.removableStorage, @@ -29,7 +29,7 @@ class AlbumTile extends StatelessWidget { color: Colors.grey, ) : null, - filter: AlbumFilter(album, uniqueName), + filter: AlbumFilter(album, displayName), ); } } diff --git a/lib/widgets/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 88661bb57..4c44049d6 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -68,7 +68,7 @@ class _AlbumPickPageState extends State { applyQuery: (filters, query) { if (query == null || query.isEmpty) return filters; query = query.toUpperCase(); - return filters.where((item) => item.filter.uniqueName.toUpperCase().contains(query)).toList(); + return filters.where((item) => item.filter.displayName.toUpperCase().contains(query)).toList(); }, emptyBuilder: () => EmptyContent( icon: AIcons.album, diff --git a/lib/widgets/filter_grids/albums_page.dart b/lib/widgets/filter_grids/albums_page.dart index 328762240..320e8657d 100644 --- a/lib/widgets/filter_grids/albums_page.dart +++ b/lib/widgets/filter_grids/albums_page.dart @@ -61,7 +61,7 @@ class AlbumListPage extends StatelessWidget { // common with album selection page to move/copy entries static Map>> getAlbumEntries(BuildContext context, CollectionSource source) { - final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(context, album))).toSet(); + final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet(); final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters); return _group(context, sorted); diff --git a/lib/widgets/filter_grids/common/chip_action_delegate.dart b/lib/widgets/filter_grids/common/chip_action_delegate.dart index 4225e4df7..fff8b977c 100644 --- a/lib/widgets/filter_grids/common/chip_action_delegate.dart +++ b/lib/widgets/filter_grids/common/chip_action_delegate.dart @@ -11,7 +11,6 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/services.dart'; -import 'package:aves/services/storage_service.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -24,7 +23,6 @@ import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:flutter/material.dart'; -import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -181,7 +179,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } // cleanup - await StorageService.deleteEmptyDirectories({album}); + await storageService.deleteEmptyDirectories({album}); }, ); } @@ -196,7 +194,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per // check whether renaming is possible given OS restrictions, // before asking to input a new name - final restrictedDirs = await StorageService.getRestrictedDirectories(); + final restrictedDirs = await storageService.getRestrictedDirectories(); final dir = VolumeRelativeDirectory.fromPath(album); if (restrictedDirs.contains(dir)) { await showRestrictedDirectoryDialog(context, dir); @@ -211,8 +209,8 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per if (!await checkStoragePermissionForAlbums(context, {album})) return; - final destinationAlbumParent = path.dirname(album); - final destinationAlbum = path.join(destinationAlbumParent, newName); + final destinationAlbumParent = pContext.dirname(album); + final destinationAlbum = pContext.join(destinationAlbumParent, newName); if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return; if (!(await File(destinationAlbum).exists())) { @@ -239,7 +237,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per } // cleanup - await StorageService.deleteEmptyDirectories({album}); + await storageService.deleteEmptyDirectories({album}); }, ); } diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index b825cf7d8..643f2fc3a 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -117,8 +117,14 @@ class CollectionSearchDelegate { StreamBuilder( stream: source.eventBus.on(), builder: (context, snapshot) { - // filter twice: full path, and then unique name - final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(context, s))).where((f) => containQuery(f.uniqueName)).toList()..sort(); + final filters = source.rawAlbums + .map((album) => AlbumFilter( + album, + source.getAlbumDisplayName(context, album), + )) + .where((filter) => containQuery(filter.album) || containQuery(filter.displayName)) + .toList() + ..sort(); return _buildFilterRow( context: context, title: context.l10n.searchSectionAlbums, diff --git a/lib/widgets/settings/access_grants.dart b/lib/widgets/settings/access_grants.dart index 4b1536c9d..ec0912290 100644 --- a/lib/widgets/settings/access_grants.dart +++ b/lib/widgets/settings/access_grants.dart @@ -1,4 +1,4 @@ -import 'package:aves/services/storage_service.dart'; +import 'package:aves/services/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/empty.dart'; @@ -39,7 +39,7 @@ class _StorageAccessPageState extends State { _load(); } - void _load() => _pathLoader = StorageService.getGrantedDirectories(); + void _load() => _pathLoader = storageService.getGrantedDirectories(); @override Widget build(BuildContext context) { @@ -87,7 +87,7 @@ class _StorageAccessPageState extends State { trailing: IconButton( icon: Icon(AIcons.clear), onPressed: () async { - await StorageService.revokeDirectoryAccess(path); + await storageService.revokeDirectoryAccess(path); _load(); setState(() {}); }, diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 5035ec065..e1f58cf21 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -82,7 +82,7 @@ class BasicSection extends StatelessWidget { if (entry.isImage && entry.is360) TypeFilter.panorama, if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo, if (entry.isVideo && !entry.is360) MimeFilter.video, - if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(context, album)), + if (album != null) AlbumFilter(album, collection?.source?.getAlbumDisplayName(context, album)), ...tags.map((tag) => TagFilter(tag)), }; return AnimatedBuilder( diff --git a/test/fake/storage_service.dart b/test/fake/storage_service.dart new file mode 100644 index 000000000..e8dddc162 --- /dev/null +++ b/test/fake/storage_service.dart @@ -0,0 +1,28 @@ +import 'package:aves/services/storage_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeStorageService extends Fake implements StorageService { + static const primaryRootAlbum = '/storage/emulated/0'; + static const primaryPath = '$primaryRootAlbum/'; + static const primaryDescription = 'Internal Storage'; + static const removablePath = '/storage/1234-5678/'; + static const removableDescription = 'SD Card'; + + @override + Future> getStorageVolumes() => SynchronousFuture({ + StorageVolume( + path: primaryPath, + description: primaryDescription, + isPrimary: true, + isRemovable: false, + ), + StorageVolume( + path: removablePath, + description: removableDescription, + isPrimary: false, + isRemovable: true, + ), + }); +} diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index e5d39881a..18fba037d 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -12,29 +12,36 @@ import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/media_store_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/services.dart'; +import 'package:aves/services/storage_service.dart'; import 'package:aves/services/time_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; import '../fake/availability.dart'; import '../fake/image_file_service.dart'; import '../fake/media_store_service.dart'; import '../fake/metadata_db.dart'; import '../fake/metadata_service.dart'; +import '../fake/storage_service.dart'; import '../fake/time_service.dart'; void main() { - const volume = '/storage/emulated/0/'; - const testAlbum = '${volume}Pictures/test'; - const sourceAlbum = '${volume}Pictures/source'; - const destinationAlbum = '${volume}Pictures/destination'; + const testAlbum = '${FakeStorageService.primaryPath}Pictures/test'; + const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source'; + const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination'; setUp(() async { + // specify Posix style path context for consistent behaviour when running tests on Windows + getIt.registerLazySingleton(() => p.Context(style: p.Style.posix)); getIt.registerLazySingleton(() => FakeAvesAvailability()); getIt.registerLazySingleton(() => FakeMetadataDb()); getIt.registerLazySingleton(() => FakeImageFileService()); getIt.registerLazySingleton(() => FakeMediaStoreService()); getIt.registerLazySingleton(() => FakeMetadataService()); + getIt.registerLazySingleton(() => FakeStorageService()); getIt.registerLazySingleton(() => FakeTimeService()); await settings.init(); @@ -236,4 +243,35 @@ void main() { expect(covers.count, 1); expect(covers.coverContentId(albumFilter), image1.contentId); }); + + testWidgets('unique album names', (tester) async { + (mediaStoreService as FakeMediaStoreService).entries = { + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Elea/Zeno', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Citium/Zeno', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Cleanthes', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Chrysippus', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Pictures/Chrysippus', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Seneca', '1'), + FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Seneca', '1'), + }; + + await androidFileUtils.init(); + final source = await _initSource(); + await tester.pumpWidget( + Builder( + builder: (context) { + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Elea/Zeno'), 'Elea/Zeno'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Citium/Zeno'), 'Citium/Zeno'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Cleanthes'), 'Cleanthes'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Chrysippus'), 'Chrysippus'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Chrysippus'), 'Chrysippus (${FakeStorageService.removableDescription})'); + expect(source.getAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca'); + expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca'); + return Placeholder(); + }, + ), + ); + }); } diff --git a/test_driver/app.dart b/test_driver/app.dart index 22a5a4fb5..23aaba037 100644 --- a/test_driver/app.dart +++ b/test_driver/app.dart @@ -5,7 +5,7 @@ import 'package:aves/model/settings/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/storage_service.dart'; import 'package:flutter_driver/driver_extension.dart'; -import 'package:path/path.dart' as path; +import 'package:path/path.dart' as p; import 'constants.dart'; @@ -15,7 +15,7 @@ void main() { // scan files copied from test assets // we do it via the app instead of broadcasting via ADB // because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29 - StorageService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'); + PlatformStorageService().scanFile(p.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg'); configureAndLaunch(); } diff --git a/test_driver/app_test.dart b/test_driver/app_test.dart index 6df4f8a3f..a79313cd2 100644 --- a/test_driver/app_test.dart +++ b/test_driver/app_test.dart @@ -1,6 +1,6 @@ import 'package:aves/model/source/enums.dart'; import 'package:flutter_driver/flutter_driver.dart'; -import 'package:path/path.dart' as path; +import 'package:path/path.dart' as p; import 'package:pedantic/pedantic.dart'; import 'package:test/test.dart'; @@ -141,9 +141,9 @@ void searchAlbum() { await driver.waitUntilNoTransientCallbacks(); const albumPath = targetPicturesDirEmulated; - final albumUniqueName = path.split(albumPath).last; + final albumDisplayName = p.split(albumPath).last; await driver.tap(find.byType('TextField')); - await driver.enterText(albumUniqueName); + await driver.enterText(albumDisplayName); final albumChip = find.byValueKey('album-$albumPath'); await driver.waitFor(albumChip); diff --git a/test_driver/utils/adb_utils.dart b/test_driver/utils/adb_utils.dart index 9eacabd7e..3377223e5 100644 --- a/test_driver/utils/adb_utils.dart +++ b/test_driver/utils/adb_utils.dart @@ -1,12 +1,12 @@ import 'dart:io'; -import 'package:path/path.dart' as path; +import 'package:path/path.dart' as p; String get adb { final env = Platform.environment; // e.g. C:\Users\\AppData\Local\Android\Sdk final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK']; - return path.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb'); + return p.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb'); } /*