Merge pull request #517 from NBA2K1/Correct-directory

Standardize Folder Structure on Windows, Linux, iOS & macOS
This commit is contained in:
Moustapha Kodjo Amadou 2025-11-08 21:47:07 +01:00 committed by GitHub
commit 24849cc000
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 194 additions and 100 deletions

View file

@ -73,22 +73,70 @@ void main(List<String> args) async {
);
}
}
isar = await StorageProvider().initDB(null, inspector: kDebugMode);
await Hive.initFlutter();
final storage = StorageProvider();
await storage.requestPermission();
await _migrateOldLayout();
isar = await storage.initDB(null, inspector: kDebugMode);
runApp(ProviderScope(child: MyApp(), retry: (retryCount, error) => null));
unawaited(_postLaunchInit(storage)); // Defer non-essential async operations
}
Future<void> _postLaunchInit(StorageProvider storage) async {
await AppLogger.init();
final hivePath = (Platform.isIOS || Platform.isMacOS)
? "databases"
: p.join("Mangayomi", "databases");
await Hive.initFlutter(Platform.isAndroid ? "" : hivePath);
Hive.registerAdapter(TrackSearchAdapter());
if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) {
discordRpc = DiscordRPC(applicationId: "1395040506677039157");
await discordRpc?.initialize();
}
runApp(ProviderScope(child: MyApp(), retry: (retryCount, error) => null));
unawaited(_postLaunchInit()); // Defer non-essential async operations
await storage.deleteBtDirectory();
}
Future<void> _postLaunchInit() async {
await StorageProvider().requestPermission();
await StorageProvider().deleteBtDirectory();
await AppLogger.init();
/// This can be removed after next release (v0.6.40?)
/// It is a one-time thing to migrate the database and folders to the new
/// iOS and macOS location (PR #517)
Future<void> _migrateOldLayout() async {
if (!(Platform.isIOS || Platform.isMacOS)) return;
final root = await getApplicationDocumentsDirectory();
final oldRoot = Directory(p.join(root.path, 'Mangayomi'));
if (!await oldRoot.exists()) return;
final newDbDir = Directory(p.join(root.path, 'databases'));
await newDbDir.create(recursive: true);
// Move database files to new directory
for (final filename in [
'mangayomiDb.isar',
'mangayomiDb.isar.lock',
'tracker_library.hive',
'tracker_library.lock',
]) {
final oldFile = File(p.join(root.path, filename));
if (await oldFile.exists()) {
final newFile = File(p.join(newDbDir.path, filename));
await oldFile.rename(newFile.path);
}
}
// Move subfolders up a level
for (final sub in ['backup', 'downloads', 'Pictures', 'local']) {
final oldSubDir = Directory(p.join(oldRoot.path, sub));
final newSubDir = Directory(p.join(root.path, sub));
if (!await oldSubDir.exists()) continue;
// If by chance newSubDir is empty, safe to rename; otherwise, move contents
if (!(await newSubDir.exists())) {
await oldSubDir.rename(newSubDir.path);
} else {
// merge contents
await for (final entity in oldSubDir.list()) {
await entity.rename(p.join(newSubDir.path, p.basename(entity.path)));
}
}
// remove subfolder if empty
if (await oldSubDir.list().isEmpty) await oldSubDir.delete();
}
// Clean up old empty folder
if (await oldRoot.list().isEmpty) await oldRoot.delete();
}
class MyApp extends ConsumerStatefulWidget {

View file

@ -88,7 +88,7 @@ Future<void> downloadChapter(
chapter,
mangaMainDirectory: mangaMainDirectory,
))!;
await Directory(chapterDirectory.path).create(recursive: true);
await storageProvider.createDirectorySafely(chapterDirectory.path);
Map<String, String> videoHeader = {};
Map<String, String> htmlHeader = {
"Priority": "u=0, i",
@ -265,6 +265,7 @@ Future<void> downloadChapter(
!mp4FileExist && itemType == ItemType.anime ||
!htmlFileExist && itemType == ItemType.novel) {
final mainDirectory = (await storageProvider.getDirectory())!;
storageProvider.createDirectorySafely(mainDirectory.path);
for (var index = 0; index < pageUrls.length; index++) {
if (Platform.isAndroid) {
if (!(await File(

View file

@ -80,42 +80,35 @@ class AutoBackupLocationState extends _$AutoBackupLocationState {
@riverpod
Future<void> checkAndBackup(Ref ref) async {
final settings = isar.settings.getSync(227);
if (settings!.backupFrequency != null) {
final backupFrequency = _duration(settings.backupFrequency);
if (backupFrequency != null) {
if (settings.startDatebackup != null) {
final startDatebackup = DateTime.fromMillisecondsSinceEpoch(
settings.startDatebackup!,
);
if (DateTime.now().isAfter(startDatebackup)) {
_setBackupFrequency(settings.backupFrequency!);
final storageProvider = StorageProvider();
await storageProvider.requestPermission();
final defaulteDirectory = await storageProvider.getDefaultDirectory();
final backupLocation = ref.watch(autoBackupLocationStateProvider).$2;
Directory? backupDirectory;
backupDirectory = Directory(
backupLocation.isEmpty
? p.join(defaulteDirectory!.path, "backup")
: backupLocation,
);
if (Platform.isIOS) {
backupDirectory = await (storageProvider.getIosBackupDirectory());
}
if (!(await backupDirectory!.exists())) {
backupDirectory.create();
}
ref.watch(
doBackUpProvider(
list: ref.watch(backupFrequencyOptionsStateProvider),
path: backupDirectory.path,
context: null,
),
);
}
}
}
final backupFrequency = _duration(settings!.backupFrequency);
if (backupFrequency == null || settings.startDatebackup == null) return;
final startDatebackup = DateTime.fromMillisecondsSinceEpoch(
settings.startDatebackup!,
);
if (!DateTime.now().isAfter(startDatebackup)) return;
_setBackupFrequency(settings.backupFrequency!);
final storageProvider = StorageProvider();
final backupLocation = ref.read(autoBackupLocationStateProvider).$2;
Directory? backupDirectory;
if (Platform.isIOS) {
backupDirectory = await (storageProvider.getIosBackupDirectory());
} else {
final defaultDirectory = await storageProvider.getDefaultDirectory();
backupDirectory = Directory(
backupLocation.isEmpty
? p.join(defaultDirectory!.path, "backup")
: backupLocation,
);
}
await storageProvider.createDirectorySafely(backupDirectory!.path);
ref.read(
doBackUpProvider(
list: ref.read(backupFrequencyOptionsStateProvider),
path: backupDirectory.path,
context: null,
),
);
}
Duration? _duration(int? backupFrequency) {

View file

@ -7,6 +7,7 @@ import 'package:extended_image_library/src/platform.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:http_client_helper/http_client_helper.dart';
import 'package:mangayomi/providers/storage_provider.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
@ -324,6 +325,7 @@ class CustomExtendedNetworkImageProvider
),
);
Uint8List? data;
await StorageProvider().createDirectorySafely(cacheImagesDirectory.path);
final File cacheFile = File(join(cacheImagesDirectory.path, md5Key));
// exist, try to find cache image file
@ -343,8 +345,6 @@ class CustomExtendedNetworkImageProvider
// Store in memory cache
_memoryCache.put(md5Key, data);
}
} else if (!cacheImagesDirectory.existsSync()) {
await cacheImagesDirectory.create(recursive: true);
}
// load from network

View file

@ -1,5 +1,6 @@
// ignore_for_file: depend_on_referenced_packages
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:isar_community/isar.dart';
import 'package:mangayomi/eval/model/source_preference.dart';
import 'package:mangayomi/main.dart';
@ -22,33 +23,28 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:path/path.dart' as path;
class StorageProvider {
static bool _hasPermission = false;
static final StorageProvider _instance = StorageProvider._internal();
StorageProvider._internal();
factory StorageProvider() => _instance;
Future<bool> requestPermission() async {
if (_hasPermission) return true;
if (Platform.isAndroid) {
Permission permission = Permission.manageExternalStorage;
if (await permission.isGranted) {
return true;
} else {
final result = await permission.request();
if (result == PermissionStatus.granted) {
_hasPermission = true;
return true;
}
return false;
}
if (!Platform.isAndroid) return true;
Permission permission = Permission.manageExternalStorage;
if (await permission.isGranted) return true;
if (await permission.request().isGranted) {
return true;
}
return true;
return false;
}
Future<void> deleteBtDirectory() async {
final d = await getBtDirectory();
await Directory(d!.path).delete(recursive: true);
final btDir = Directory(await _btDirectoryPath());
if (await btDir.exists()) await btDir.delete(recursive: true);
}
Future<void> deleteTmpDirectory() async {
final d = await getTmpDirectory();
await Directory(d!.path).delete(recursive: true);
final tmpDir = Directory(await _tempDirectoryPath());
if (await tmpDir.exists()) await tmpDir.delete(recursive: true);
}
Future<Directory?> getDefaultDirectory() async {
@ -57,6 +53,10 @@ class StorageProvider {
directory = Directory("/storage/emulated/0/Mangayomi/");
} else {
final dir = await getApplicationDocumentsDirectory();
// The documents dir in iOS and macOS is already named "Mangayomi".
// Appending "Mangayomi" to the documents dir would create
// unnecessarily nested Mangayomi/Mangayomi/ folder.
if (Platform.isIOS || Platform.isMacOS) return dir;
directory = Directory(path.join(dir.path, 'Mangayomi'));
}
return directory;
@ -70,29 +70,43 @@ class StorageProvider {
}
Future<Directory?> getBtDirectory() async {
final gefaultDirectory = await getDefaultDirectory();
String dbDir = path.join(gefaultDirectory!.path, 'torrents');
await Directory(dbDir).create(recursive: true);
final dbDir = await _btDirectoryPath();
await createDirectorySafely(dbDir);
return Directory(dbDir);
}
Future<String> _btDirectoryPath() async {
final defaultDirectory = await getDefaultDirectory();
return path.join(defaultDirectory!.path, 'torrents');
}
Future<Directory?> getTmpDirectory() async {
final gefaultDirectory = await getDirectory();
String dbDir = path.join(gefaultDirectory!.path, 'tmp');
await Directory(dbDir).create(recursive: true);
return Directory(dbDir);
final tmpPath = await _tempDirectoryPath();
await createDirectorySafely(tmpPath);
return Directory(tmpPath);
}
Future<String> _tempDirectoryPath() async {
final defaultDirectory = await getDirectory();
return path.join(defaultDirectory!.path, 'tmp');
}
Future<Directory?> getIosBackupDirectory() async {
final gefaultDirectory = await getDefaultDirectory();
String dbDir = path.join(gefaultDirectory!.path, 'backup');
await Directory(dbDir).create(recursive: true);
final defaultDirectory = await getDefaultDirectory();
String dbDir = path.join(defaultDirectory!.path, 'backup');
await createDirectorySafely(dbDir);
return Directory(dbDir);
}
Future<Directory?> getDirectory() async {
Directory? directory;
String dPath = isar.settings.getSync(227)!.downloadLocation ?? "";
String dPath = "";
try {
final setting = isar.settings.getSync(227);
dPath = setting?.downloadLocation ?? "";
} catch (e) {
debugPrint("Could not get downloadLocation from Isar settings: $e");
}
if (Platform.isAndroid) {
directory = Directory(
dPath.isEmpty ? "/storage/emulated/0/Mangayomi/" : "$dPath/",
@ -100,6 +114,10 @@ class StorageProvider {
} else {
final dir = await getApplicationDocumentsDirectory();
final p = dPath.isEmpty ? dir.path : dPath;
// The documents dir in iOS and macOS is already named "Mangayomi".
// Appending "Mangayomi" to the documents dir would create
// unnecessarily nested Mangayomi/Mangayomi/ folder.
if (Platform.isIOS || Platform.isMacOS) return Directory(p);
directory = Directory(path.join(p, 'Mangayomi'));
}
return directory;
@ -143,27 +161,48 @@ class StorageProvider {
Future<Directory?> getDatabaseDirectory() async {
final dir = await getApplicationDocumentsDirectory();
if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) {
return dir;
String dbDir;
if (Platform.isAndroid) return dir;
if (Platform.isIOS || Platform.isMacOS) {
// Put the database files inside /databases like on Windows, Linux
// So they are not just in the app folders root dir
dbDir = path.join(dir.path, 'databases');
} else {
String dbDir = path.join(dir.path, 'Mangayomi', 'databases');
await Directory(dbDir).create(recursive: true);
return Directory(dbDir);
dbDir = path.join(dir.path, 'Mangayomi', 'databases');
}
await createDirectorySafely(dbDir);
return Directory(dbDir);
}
Future<Directory?> getGalleryDirectory() async {
String gPath = (await getDirectory())!.path;
String gPath;
if (Platform.isAndroid) {
gPath = "/storage/emulated/0/Pictures/Mangayomi/";
} else {
gPath = path.join(gPath, 'Pictures');
gPath = path.join((await getDirectory())!.path, 'Pictures');
}
await Directory(gPath).create(recursive: true);
await createDirectorySafely(gPath);
return Directory(gPath);
}
Future<Isar> initDB(String? path, {bool? inspector = false}) async {
Future<void> createDirectorySafely(String dirPath) async {
final dir = Directory(dirPath);
try {
await dir.create(recursive: true);
} catch (_) {
if (await requestPermission()) {
try {
await dir.create(recursive: true);
} catch (e) {
debugPrint('Initial directory creation failed for $dirPath: $e');
}
} else {
debugPrint('Permission denied. Cannot create: $dirPath');
}
}
}
Future<Isar> initDB(String? path, {bool inspector = false}) async {
Directory? dir;
if (path == null) {
dir = await getDatabaseDirectory();
@ -191,14 +230,29 @@ class StorageProvider {
],
directory: dir!.path,
name: "mangayomiDb",
inspector: inspector!,
inspector: inspector,
);
final settings = await isar.settings.filter().idEqualTo(227).findFirst();
if (settings == null) {
await isar.writeTxn(() async {
isar.settings.put(Settings());
});
try {
final settings = await isar.settings.filter().idEqualTo(227).findFirst();
if (settings == null) {
await isar.writeTxn(() async => isar.settings.put(Settings()));
}
} catch (_) {
if (await requestPermission()) {
try {
final settings = await isar.settings
.filter()
.idEqualTo(227)
.findFirst();
if (settings == null) {
await isar.writeTxn(() async => isar.settings.put(Settings()));
}
} catch (e) {
debugPrint("Failed after retry with permission: $e");
}
} else {
debugPrint("Permission denied during Database init fallback.");
}
}
final prefs = await isar.trackPreferences

View file

@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:mangayomi/models/chapter.dart';
import 'package:mangayomi/models/video.dart';
import 'package:mangayomi/providers/storage_provider.dart';
import 'package:mangayomi/services/http/m_client.dart';
import 'package:mangayomi/services/http/rhttp/src/model/settings.dart';
import 'package:mangayomi/services/download_manager/m3u8/models/download.dart';
@ -124,21 +125,18 @@ class M3u8Downloader {
}
Future<void> download(void Function(DownloadProgress) onProgress) async {
final tempDir = Directory(path.join(downloadDir, 'temp'));
final tempDir = path.join(downloadDir, 'temp');
await StorageProvider().createDirectorySafely(tempDir);
try {
await tempDir.create(recursive: true);
final (tsList, key, iv, mediaSequence) = await _getTsList();
final tsListToDownload = await _filterExistingSegments(
tsList,
tempDir.path,
);
final tsListToDownload = await _filterExistingSegments(tsList, tempDir);
_log('Downloading ${tsListToDownload.length} segments...');
await _downloadSegmentsWithProgress(
tsListToDownload,
tempDir.path,
tempDir,
key,
iv,
mediaSequence,