albums: distinct naming improvements & tests, localized common albums

This commit is contained in:
Thibault Deckers 2021-03-23 17:41:05 +09:00
parent 0464bd8678
commit 0db76a46de
34 changed files with 241 additions and 108 deletions

View file

@ -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

View file

@ -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) {

View file

@ -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",

View file

@ -201,6 +201,11 @@
"albumPickPageTitleExport": "앨범으로 내보내기",
"albumPickPageTitleMove": "앨범으로 이동",
"albumCamera": "카메라",
"albumDownload": "다운로드",
"albumScreenshots": "스크린샷",
"albumScreenRecordings": "화면 녹화 파일",
"albumPageTitle": "앨범",
"albumEmpty": "앨범이 없습니다",
"createAlbumTooltip": "새 앨범 만들기",

View file

@ -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;
}

View file

@ -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<String, Color> _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<String, dynamic> json)
: this(
@ -28,14 +28,14 @@ class AlbumFilter extends CollectionFilter {
Map<String, dynamic> 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;

View file

@ -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> _database;
Future<String> get path async => join(await getDatabasesPath(), 'metadata.db');
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
static const entryTable = 'entry';
static const dateTakenTable = 'dateTaken';

View file

@ -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<String> _directories = {};
@ -13,8 +14,8 @@ mixin AlbumMixin on SourceBase {
List<String> 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,30 +25,45 @@ 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<String> 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;
} else {
final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath));
}
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;
@ -55,7 +71,6 @@ mixin AlbumMixin on SourceBase {
return '$uniqueNameInVolume (${volume.getDescription(context)})';
}
}
}
Map<String, AvesEntry> getAlbumEntries() {
final entries = sortedEntriesByDate;

View file

@ -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<p.Context>();
final availability = getIt<AvesAvailability>();
final metadataDb = getIt<MetadataDb>();
final imageFileService = getIt<ImageFileService>();
final mediaStoreService = getIt<MediaStoreService>();
final metadataService = getIt<MetadataService>();
final storageService = getIt<StorageService>();
final timeService = getIt<TimeService>();
void initPlatformServices() {
getIt.registerLazySingleton<p.Context>(() => p.Context());
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
}

View file

@ -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<Set<StorageVolume>> getStorageVolumes();
Future<int> getFreeSpace(StorageVolume volume);
Future<List<String>> getGrantedDirectories();
Future<void> revokeDirectoryAccess(String path);
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths);
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories();
// returns whether user granted access to volume root at `volumePath`
Future<bool> requestVolumeAccess(String volumePath);
// returns number of deleted directories
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
// returns media URI
Future<Uri> 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<Set<StorageVolume>> getStorageVolumes() async {
@override
Future<Set<StorageVolume>> getStorageVolumes() async {
try {
final result = await platform.invokeMethod('getStorageVolumes');
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
@ -19,7 +43,8 @@ class StorageService {
return {};
}
static Future<int> getFreeSpace(StorageVolume volume) async {
@override
Future<int> getFreeSpace(StorageVolume volume) async {
try {
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
'path': volume.path,
@ -31,7 +56,8 @@ class StorageService {
return 0;
}
static Future<List<String>> getGrantedDirectories() async {
@override
Future<List<String>> getGrantedDirectories() async {
try {
final result = await platform.invokeMethod('getGrantedDirectories');
return (result as List).cast<String>();
@ -41,7 +67,8 @@ class StorageService {
return [];
}
static Future<void> revokeDirectoryAccess(String path) async {
@override
Future<void> revokeDirectoryAccess(String path) async {
try {
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
'path': path,
@ -52,7 +79,8 @@ class StorageService {
return;
}
static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
@override
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
try {
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(),
@ -64,7 +92,8 @@ class StorageService {
return null;
}
static Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
@override
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
try {
final result = await platform.invokeMethod('getRestrictedDirectories');
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
@ -75,7 +104,8 @@ class StorageService {
}
// returns whether user granted access to volume root at `volumePath`
static Future<bool> requestVolumeAccess(String volumePath) async {
@override
Future<bool> requestVolumeAccess(String volumePath) async {
try {
final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
@ -96,7 +126,8 @@ class StorageService {
}
// returns number of deleted directories
static Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
@override
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
try {
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
'dirPaths': dirPaths.toList(),
@ -108,7 +139,8 @@ class StorageService {
}
// returns media URI
static Future<Uri> scanFile(String path, String mimeType) async {
@override
Future<Uri> scanFile(String path, String mimeType) async {
debugPrint('scanFile with path=$path, mimeType=$mimeType');
try {
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{

View file

@ -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<void> 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<void> 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;
}

View file

@ -28,7 +28,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
case EntryGroupFactor.album:
return [
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
if (_hasMultipleSections(context)) context.read<CollectionSource>().getUniqueAlbumName(context, entry.directory),
if (_hasMultipleSections(context)) context.read<CollectionSource>().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<CollectionSource>().getUniqueAlbumName(context, entry.directory),
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
entry.bestTitle,
];
case EntrySortFactor.size:

View file

@ -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);
},
);
}

View file

@ -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),
);

View file

@ -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),
);
}

View file

@ -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<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> 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;

View file

@ -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) {

View file

@ -17,7 +17,7 @@ class DraggableThumbLabel<T> extends StatelessWidget {
Widget build(BuildContext context) {
final sll = context.read<SectionedListLayout<T>>();
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);

View file

@ -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<DebugStorageSection> 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);
});
}

View file

@ -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<CreateAlbumDialog> {
String _buildAlbumPath(String name) {
if (name == null || name.isEmpty) return '';
return join(_selectedVolume.path, 'Pictures', name);
return pContext.join(_selectedVolume.path, 'Pictures', name);
}
Future<void> _validate() async {

View file

@ -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<RenameAlbumDialog> {
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<RenameAlbumDialog> {
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<void> _validate() async {

View file

@ -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<RenameEntryDialog> {
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<void> _validate() async {

View file

@ -15,13 +15,13 @@ class AlbumTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final source = context.read<CollectionSource>();
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),
);
}
}

View file

@ -68,7 +68,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
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,

View file

@ -61,7 +61,7 @@ class AlbumListPage extends StatelessWidget {
// common with album selection page to move/copy entries
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> 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);

View file

@ -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});
},
);
}

View file

@ -117,8 +117,14 @@ class CollectionSearchDelegate {
StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
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,

View file

@ -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<StorageAccessPage> {
_load();
}
void _load() => _pathLoader = StorageService.getGrantedDirectories();
void _load() => _pathLoader = storageService.getGrantedDirectories();
@override
Widget build(BuildContext context) {
@ -87,7 +87,7 @@ class _StorageAccessPageState extends State<StorageAccessPage> {
trailing: IconButton(
icon: Icon(AIcons.clear),
onPressed: () async {
await StorageService.revokeDirectoryAccess(path);
await storageService.revokeDirectoryAccess(path);
_load();
setState(() {});
},

View file

@ -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(

View file

@ -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<Set<StorageVolume>> getStorageVolumes() => SynchronousFuture({
StorageVolume(
path: primaryPath,
description: primaryDescription,
isPrimary: true,
isRemovable: false,
),
StorageVolume(
path: removablePath,
description: removableDescription,
isPrimary: false,
isRemovable: true,
),
});
}

View file

@ -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>(() => p.Context(style: p.Style.posix));
getIt.registerLazySingleton<AvesAvailability>(() => FakeAvesAvailability());
getIt.registerLazySingleton<MetadataDb>(() => FakeMetadataDb());
getIt.registerLazySingleton<ImageFileService>(() => FakeImageFileService());
getIt.registerLazySingleton<MediaStoreService>(() => FakeMediaStoreService());
getIt.registerLazySingleton<MetadataService>(() => FakeMetadataService());
getIt.registerLazySingleton<StorageService>(() => FakeStorageService());
getIt.registerLazySingleton<TimeService>(() => 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();
},
),
);
});
}

View file

@ -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();
}

View file

@ -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);

View file

@ -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\<username>\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');
}
/*