Files
venera/lib/foundation/local.dart
nyne 430b6eeb3a Feat/saf (#81)
* [Android] Use SAF to change local path

* Use IOOverrides to replace openDirectoryPlatform and openFilePlatform

* fix io
2024-11-29 21:33:28 +08:00

506 lines
13 KiB
Dart

import 'dart:convert';
import 'package:flutter/widgets.dart' show ChangeNotifier;
import 'package:path_provider/path_provider.dart';
import 'package:sqlite3/sqlite3.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/log.dart';
import 'package:venera/network/download.dart';
import 'package:venera/pages/reader/reader.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'app.dart';
import 'history.dart';
class LocalComic with HistoryMixin implements Comic {
@override
final String id;
@override
final String title;
@override
final String subtitle;
@override
final List<String> tags;
/// The name of the directory where the comic is stored
final String directory;
/// key: chapter id, value: chapter title
///
/// chapter id is the name of the directory in `LocalManager.path/$directory`
final Map<String, String>? chapters;
/// relative path to the cover image
@override
final String cover;
final ComicType comicType;
final List<String> downloadedChapters;
final DateTime createdAt;
const LocalComic({
required this.id,
required this.title,
required this.subtitle,
required this.tags,
required this.directory,
required this.chapters,
required this.cover,
required this.comicType,
required this.downloadedChapters,
required this.createdAt,
});
LocalComic.fromRow(Row row)
: id = row[0] as String,
title = row[1] as String,
subtitle = row[2] as String,
tags = List.from(jsonDecode(row[3] as String)),
directory = row[4] as String,
chapters = MapOrNull.from(jsonDecode(row[5] as String)),
cover = row[6] as String,
comicType = ComicType(row[7] as int),
downloadedChapters = List.from(jsonDecode(row[8] as String)),
createdAt = DateTime.fromMillisecondsSinceEpoch(row[9] as int);
File get coverFile => File(FilePath.join(
baseDir,
cover,
));
String get baseDir => directory.contains("/") ? directory : FilePath.join(LocalManager().path, directory);
@override
String get description => "";
@override
String get sourceKey => comicType == ComicType.local
? "local"
: comicType.sourceKey;
@override
Map<String, dynamic> toJson() {
return {
"title": title,
"cover": cover,
"id": id,
"subTitle": subtitle,
"tags": tags,
"description": description,
"sourceKey": sourceKey,
};
}
@override
int? get maxPage => null;
void read() async {
var history = await HistoryManager().find(id, comicType);
App.rootContext.to(
() => Reader(
type: comicType,
cid: id,
name: title,
chapters: chapters,
initialChapter: history?.ep,
initialPage: history?.page,
history: history ?? History.fromModel(
model: this,
ep: 0,
page: 0,
),
),
);
}
@override
HistoryType get historyType => comicType;
@override
String? get subTitle => subtitle;
@override
String? get language => null;
@override
String? get favoriteId => null;
@override
double? get stars => null;
}
class LocalManager with ChangeNotifier {
static LocalManager? _instance;
LocalManager._();
factory LocalManager() {
return _instance ??= LocalManager._();
}
late Database _db;
/// path to the directory where all the comics are stored
late String path;
Directory get directory => Directory(path);
// return error message if failed
Future<String?> setNewPath(String newPath) async {
var newDir = Directory(newPath);
if (!await newDir.exists()) {
return "Directory does not exist";
}
if (!await newDir.list().isEmpty) {
return "Directory is not empty";
}
try {
await copyDirectoryIsolate(
directory,
newDir,
);
await File(FilePath.join(App.dataPath, 'local_path')).writeAsString(newPath);
} catch (e, s) {
Log.error("IO", e, s);
return e.toString();
}
await directory.deleteContents(recursive: true);
path = newPath;
return null;
}
Future<String> findDefaultPath() async {
if (App.isAndroid) {
var external = await getExternalStorageDirectories();
if (external != null && external.isNotEmpty) {
return FilePath.join(external.first.path, 'local');
} else {
return FilePath.join(App.dataPath, 'local');
}
} else if (App.isIOS) {
var oldPath = FilePath.join(App.dataPath, 'local');
if (Directory(oldPath).existsSync() && Directory(oldPath).listSync().isNotEmpty) {
return oldPath;
} else {
var directory = await getApplicationDocumentsDirectory();
return FilePath.join(directory.path, 'local');
}
} else {
return FilePath.join(App.dataPath, 'local');
}
}
Future<void> init() async {
_db = sqlite3.open(
'${App.dataPath}/local.db',
);
_db.execute('''
CREATE TABLE IF NOT EXISTS comics (
id TEXT NOT NULL,
title TEXT NOT NULL,
subtitle TEXT NOT NULL,
tags TEXT NOT NULL,
directory TEXT NOT NULL,
chapters TEXT NOT NULL,
cover TEXT NOT NULL,
comic_type INTEGER NOT NULL,
downloadedChapters TEXT NOT NULL,
created_at INTEGER,
PRIMARY KEY (id, comic_type)
);
''');
if (File(FilePath.join(App.dataPath, 'local_path')).existsSync()) {
path = File(FilePath.join(App.dataPath, 'local_path')).readAsStringSync();
if (!directory.existsSync()) {
path = await findDefaultPath();
}
} else {
path = await findDefaultPath();
}
try {
if (!directory.existsSync()) {
await directory.create();
}
}
catch(e, s) {
Log.error("IO", "Failed to create local folder: $e", s);
}
restoreDownloadingTasks();
}
String findValidId(ComicType type) {
final res = _db.select(
'''
SELECT id FROM comics WHERE comic_type = ?
ORDER BY CAST(id AS INTEGER) DESC
LIMIT 1;
''', [type.value],
);
if (res.isEmpty) {
return '1';
}
return (int.parse((res.first[0])) + 1).toString();
}
Future<void> add(LocalComic comic, [String? id]) async {
var old = find(id ?? comic.id, comic.comicType);
var downloaded = comic.downloadedChapters;
if (old != null) {
downloaded.addAll(old.downloadedChapters);
}
_db.execute(
'INSERT OR REPLACE INTO comics VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
[
id ?? comic.id,
comic.title,
comic.subtitle,
jsonEncode(comic.tags),
comic.directory,
jsonEncode(comic.chapters),
comic.cover,
comic.comicType.value,
jsonEncode(downloaded),
comic.createdAt.millisecondsSinceEpoch,
],
);
notifyListeners();
}
void remove(String id, ComicType comicType) async {
_db.execute(
'DELETE FROM comics WHERE id = ? AND comic_type = ?;',
[id, comicType.value],
);
notifyListeners();
}
void removeComic(LocalComic comic) {
remove(comic.id, comic.comicType);
notifyListeners();
}
List<LocalComic> getComics(LocalSortType sortType) {
var res = _db.select('''
SELECT * FROM comics
ORDER BY
${sortType.value == 'name' ? 'title' : 'created_at'}
${sortType.value == 'time_asc' ? 'ASC' : 'DESC'}
;
''');
return res.map((row) => LocalComic.fromRow(row)).toList();
}
LocalComic? find(String id, ComicType comicType) {
final res = _db.select(
'SELECT * FROM comics WHERE id = ? AND comic_type = ?;',
[id, comicType.value],
);
if (res.isEmpty) {
return null;
}
return LocalComic.fromRow(res.first);
}
@override
void dispose() {
super.dispose();
_db.dispose();
}
List<LocalComic> getRecent() {
final res = _db.select('''
SELECT * FROM comics
ORDER BY created_at DESC
LIMIT 20;
''');
return res.map((row) => LocalComic.fromRow(row)).toList();
}
int get count {
final res = _db.select('''
SELECT COUNT(*) FROM comics;
''');
return res.first[0] as int;
}
LocalComic? findByName(String name) {
final res = _db.select('''
SELECT * FROM comics
WHERE title = ? OR directory = ?;
''', [name, name]);
if (res.isEmpty) {
return null;
}
return LocalComic.fromRow(res.first);
}
List<LocalComic> search(String keyword) {
final res = _db.select('''
SELECT * FROM comics
WHERE title LIKE ? OR tags LIKE ? OR subtitle LIKE ?
ORDER BY created_at DESC;
''', ['%$keyword%', '%$keyword%', '%$keyword%']);
return res.map((row) => LocalComic.fromRow(row)).toList();
}
Future<List<String>> getImages(String id, ComicType type, Object ep) async {
if(ep is! String && ep is! int) {
throw "Invalid ep";
}
var comic = find(id, type) ?? (throw "Comic Not Found");
var directory = Directory(comic.baseDir);
if (comic.chapters != null) {
var cid = ep is int
? comic.chapters!.keys.elementAt(ep - 1)
: (ep as String);
directory = Directory(FilePath.join(directory.path, cid));
}
var files = <File>[];
await for (var entity in directory.list()) {
if (entity is File) {
// Do not exclude comic.cover, since it may be the first page of the chapter.
// A file with name starting with 'cover.' is not a comic page.
if (entity.name.startsWith('cover.')) {
continue;
}
//Hidden file in some file system
if(entity.name.startsWith('.')) {
continue;
}
files.add(entity);
}
}
files.sort((a, b) {
var ai = int.tryParse(a.name.split('.').first);
var bi = int.tryParse(b.name.split('.').first);
if (ai != null && bi != null) {
return ai.compareTo(bi);
}
return a.name.compareTo(b.name);
});
return files.map((e) => "file://${e.path}").toList();
}
Future<bool> isDownloaded(String id, ComicType type, [int? ep]) async {
var comic = find(id, type);
if (comic == null) return false;
if (comic.chapters == null || ep == null) return true;
return comic.downloadedChapters
.contains(comic.chapters!.keys.elementAt(ep-1));
}
List<DownloadTask> downloadingTasks = [];
bool isDownloading(String id, ComicType type) {
return downloadingTasks
.any((element) => element.id == id && element.comicType == type);
}
Future<Directory> findValidDirectory(
String id, ComicType type, String name) async {
var comic = find(id, type);
if (comic != null) {
return Directory(FilePath.join(path, comic.directory));
}
var dir = findValidDirectoryName(path, name);
return Directory(FilePath.join(path, dir)).create().then((value) => value);
}
void completeTask(DownloadTask task) {
add(task.toLocalComic());
downloadingTasks.remove(task);
notifyListeners();
saveCurrentDownloadingTasks();
downloadingTasks.firstOrNull?.resume();
}
void removeTask(DownloadTask task) {
downloadingTasks.remove(task);
notifyListeners();
saveCurrentDownloadingTasks();
}
void moveToFirst(DownloadTask task) {
if (downloadingTasks.first != task) {
var shouldResume = !downloadingTasks.first.isPaused;
downloadingTasks.first.pause();
downloadingTasks.remove(task);
downloadingTasks.insert(0, task);
notifyListeners();
saveCurrentDownloadingTasks();
if (shouldResume) {
downloadingTasks.first.resume();
}
}
}
Future<void> saveCurrentDownloadingTasks() async {
var tasks = downloadingTasks.map((e) => e.toJson()).toList();
await File(FilePath.join(App.dataPath, 'downloading_tasks.json'))
.writeAsString(jsonEncode(tasks));
}
void restoreDownloadingTasks() {
var file = File(FilePath.join(App.dataPath, 'downloading_tasks.json'));
if (file.existsSync()) {
var tasks = jsonDecode(file.readAsStringSync());
for (var e in tasks) {
var task = DownloadTask.fromJson(e);
if (task != null) {
downloadingTasks.add(task);
}
}
}
}
void addTask(DownloadTask task) {
downloadingTasks.add(task);
notifyListeners();
saveCurrentDownloadingTasks();
downloadingTasks.first.resume();
}
void deleteComic(LocalComic c, [bool removeFileOnDisk = true]) {
if(removeFileOnDisk) {
var dir = Directory(FilePath.join(path, c.directory));
dir.deleteIgnoreError(recursive: true);
}
//Deleting a local comic means that it's nolonger available, thus both favorite and history should be deleted.
if(HistoryManager().findSync(c.id, c.comicType) != null) {
HistoryManager().remove(c.id, c.comicType);
}
var folders = LocalFavoritesManager().find(c.id, c.comicType);
for (var f in folders) {
LocalFavoritesManager().deleteComicWithId(f, c.id, c.comicType);
}
remove(c.id, c.comicType);
notifyListeners();
}
}
enum LocalSortType {
name("name"),
timeAsc("time_asc"),
timeDesc("time_desc");
final String value;
const LocalSortType(this.value);
static LocalSortType fromString(String value) {
for (var type in values) {
if (type.value == value) {
return type;
}
}
return name;
}
}