import 'package:aves/services/android_app_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:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; final AndroidFileUtils androidFileUtils = AndroidFileUtils._private(); class AndroidFileUtils { late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath; Set storageVolumes = {}; Set _packages = {}; List _potentialAppDirs = []; AChangeNotifier appNameChangeNotifier = AChangeNotifier(); Iterable get _launcherPackages => _packages.where((package) => package.categoryLauncher); AndroidFileUtils._private(); Future init() async { separator = pContext.separator; storageVolumes = await storageService.getStorageVolumes(); primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator; // standard dcimPath = pContext.join(primaryStorage, 'DCIM'); downloadPath = pContext.join(primaryStorage, 'Download'); moviesPath = pContext.join(primaryStorage, 'Movies'); picturesPath = pContext.join(primaryStorage, 'Pictures'); // from Aves videoCapturesPath = pContext.join(dcimPath, 'Video Captures'); } Future initAppNames() async { _packages = await AndroidAppService.getPackages(); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList(); appNameChangeNotifier.notifyListeners(); } bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO')); bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots'); bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('${separator}Screen recordings') || path.endsWith('${separator}ScreenRecords')); bool isVideoCapturesPath(String path) => path == videoCapturesPath; bool isDownloadPath(String path) => path == downloadPath; StorageVolume? getStorageVolume(String? path) { if (path == null) return null; final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path)); // storage volume path includes trailing '/', but argument path may or may not, // which is an issue when the path is at the root return volume != null || path.endsWith(separator) ? volume : getStorageVolume('$path$separator'); } bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false; AlbumType getAlbumType(String albumPath) { if (isCameraPath(albumPath)) return AlbumType.camera; if (isDownloadPath(albumPath)) return AlbumType.download; if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings; if (isScreenshotsPath(albumPath)) return AlbumType.screenshots; if (isVideoCapturesPath(albumPath)) return AlbumType.videoCaptures; final dir = pContext.split(albumPath).last; if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app; return AlbumType.regular; } String? getAlbumAppPackageName(String albumPath) { final dir = pContext.split(albumPath).last; final package = _launcherPackages.firstWhereOrNull((package) => package.potentialDirs.contains(dir)); return package?.packageName; } String? getCurrentAppName(String packageName) { final package = _packages.firstWhereOrNull((package) => package.packageName == packageName); return package?.currentLabel; } } enum AlbumType { regular, app, camera, download, screenRecordings, screenshots, videoCaptures } class Package { final String packageName; final String? currentLabel, englishLabel; final bool categoryLauncher, isSystem; final Set ownedDirs = {}; Package({ required this.packageName, required this.currentLabel, required this.englishLabel, required this.categoryLauncher, required this.isSystem, }); factory Package.fromMap(Map map) { return Package( packageName: map['packageName'] ?? '', currentLabel: map['currentLabel'], englishLabel: map['englishLabel'], categoryLauncher: map['categoryLauncher'] ?? false, isSystem: map['isSystem'] ?? false, ); } Set get potentialDirs => [ currentLabel, englishLabel, ...ownedDirs, ].whereNotNull().toSet(); @override String toString() => '$runtimeType#${shortHash(this)}{packageName=$packageName, categoryLauncher=$categoryLauncher, isSystem=$isSystem, currentLabel=$currentLabel, englishLabel=$englishLabel, ownedDirs=$ownedDirs}'; } @immutable class StorageVolume { final String? _description; final String path, state; final bool isPrimary, isRemovable; const StorageVolume({ required String? description, required this.isPrimary, required this.isRemovable, required this.path, required this.state, }) : _description = description; String getDescription(BuildContext? context) { if (_description != null) return _description!; // ideally, the context should always be provided, but in some cases (e.g. album comparison), // this would require numerous additional methods to have the context as argument // for such a minor benefit: fallback volume description on Android < N if (isPrimary) return context?.l10n.storageVolumeDescriptionFallbackPrimary ?? 'Internal Storage'; return context?.l10n.storageVolumeDescriptionFallbackNonPrimary ?? 'SD card'; } factory StorageVolume.fromMap(Map map) { final isPrimary = map['isPrimary'] ?? false; return StorageVolume( description: map['description'], isPrimary: isPrimary, isRemovable: map['isRemovable'] ?? false, path: map['path'] ?? '', state: map['state'] ?? '', ); } } @immutable class VolumeRelativeDirectory extends Equatable { final String volumePath, relativeDir; @override List get props => [volumePath, relativeDir]; const VolumeRelativeDirectory({ required this.volumePath, required this.relativeDir, }); static VolumeRelativeDirectory fromMap(Map map) { return VolumeRelativeDirectory( volumePath: map['volumePath'] ?? '', relativeDir: map['relativeDir'] ?? '', ); } // prefer static method over a null returning factory constructor static VolumeRelativeDirectory? fromPath(String dirPath) { final volume = androidFileUtils.getStorageVolume(dirPath); if (volume == null) return null; final root = volume.path; final rootLength = root.length; return VolumeRelativeDirectory( volumePath: root, relativeDir: dirPath.length < rootLength ? '' : dirPath.substring(rootLength), ); } String getVolumeDescription(BuildContext context) { final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath); return volume?.getDescription(context) ?? volumePath; } }