generated from dellevin/template
优化项目结构
This commit is contained in:
32
lib/services/app_icon_channel.dart
Normal file
32
lib/services/app_icon_channel.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 应用图标原生通道
|
||||
/// 通过 MethodChannel 调用 Android activity-alias 切换桌面图标
|
||||
class AppIconChannel {
|
||||
static const MethodChannel _channel =
|
||||
MethodChannel('top.iletter.mooknote/icon');
|
||||
|
||||
/// 切换桌面图标
|
||||
/// [iconName] 图标名称,如 'app_icon' 或 'app_icon2'
|
||||
/// 返回是否成功
|
||||
static Future<bool> switchIcon(String iconName) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('switchIcon', {
|
||||
'iconName': iconName,
|
||||
});
|
||||
return result == true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前启用的图标名称
|
||||
static Future<String> getCurrentIcon() async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getCurrentIcon');
|
||||
return result as String? ?? 'app_icon';
|
||||
} catch (e) {
|
||||
return 'app_icon';
|
||||
}
|
||||
}
|
||||
}
|
||||
93
lib/services/changelog_service.dart
Normal file
93
lib/services/changelog_service.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'server_config.dart';
|
||||
|
||||
/// 更新日志数据模型
|
||||
class ChangelogItem {
|
||||
final String version;
|
||||
final String date;
|
||||
final List<String> features;
|
||||
|
||||
ChangelogItem({
|
||||
required this.version,
|
||||
required this.date,
|
||||
required this.features,
|
||||
});
|
||||
|
||||
factory ChangelogItem.fromJson(Map<String, dynamic> json) {
|
||||
return ChangelogItem(
|
||||
version: json['version'] ?? '',
|
||||
date: json['date'] ?? '',
|
||||
features: (json['features'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 版本更新检查服务
|
||||
class ChangelogService {
|
||||
static final _apiUrl = '${ServerConfig.apiBase}/changelog';
|
||||
|
||||
/// 获取更新日志列表
|
||||
static Future<List<ChangelogItem>> fetchChangelog() async {
|
||||
try {
|
||||
final resp = await http
|
||||
.get(Uri.parse(_apiUrl))
|
||||
.timeout(const Duration(seconds: 5));
|
||||
if (resp.statusCode == 200) {
|
||||
final data = jsonDecode(resp.body);
|
||||
final items = (data['items'] as List<dynamic>?)
|
||||
?.map((e) => ChangelogItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
return items;
|
||||
}
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// 获取最新版本号
|
||||
static Future<String?> fetchLatestVersion() async {
|
||||
final items = await fetchChangelog();
|
||||
if (items.isNotEmpty) return items.first.version;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 比较两版本号,a > b 则返回 1,a < b 返回 -1,相等返回 0
|
||||
/// v0.1.9 → 当成数字 "0.19" = 0.19,0.1.88 → "0.188" = 0.188,所以 0.19 > 0.188
|
||||
/// 实现方式:去掉 v,把第一个点后的数字拼接再转 double 比较
|
||||
static int compareVersion(String a, String b) {
|
||||
double toNum(String v) {
|
||||
final s = v.replaceFirst('v', '');
|
||||
final dot = s.indexOf('.');
|
||||
if (dot == -1) return double.tryParse(s) ?? 0;
|
||||
// "0.1.9" → "0." + "19" = "0.19";"0.1.88" → "0." + "188" = "0.188"
|
||||
final major = s.substring(0, dot + 1); // "0."
|
||||
final rest = s.substring(dot + 1).replaceAll('.', ''); // "19" 或 "188"
|
||||
return double.tryParse('$major$rest') ?? 0;
|
||||
}
|
||||
|
||||
final aVal = toNum(a);
|
||||
final bVal = toNum(b);
|
||||
debugPrint('[Update] compare: "$a"→$aVal vs "$b"→$bVal');
|
||||
if (aVal > bVal) return 1;
|
||||
if (aVal < bVal) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 检查是否有新版本(远程 > 本地)
|
||||
static Future<bool> hasUpdate() async {
|
||||
final latest = await fetchLatestVersion();
|
||||
if (latest == null) return false;
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final local = 'v${info.version}';
|
||||
debugPrint('[Update] 远程: $latest, 本地: $local');
|
||||
final result = compareVersion(latest, local) > 0;
|
||||
debugPrint('[Update] 远程 > 本地? $result');
|
||||
return result;
|
||||
}
|
||||
}
|
||||
472
lib/services/epub/epub_parser.dart
Normal file
472
lib/services/epub/epub_parser.dart
Normal file
@@ -0,0 +1,472 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
import 'reader_models.dart';
|
||||
|
||||
/// EPUB 解析器 - 从 ZIP 归档中解析 EPUB 结构
|
||||
class EpubParser {
|
||||
/// 从文件路径解析 EPUB
|
||||
Future<EpubBookInfo?> parseFromFile(String filePath,
|
||||
{String? fileName}) async {
|
||||
try {
|
||||
final bytes = await File(filePath).readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
return _parseFromArchive(archive, fileName: fileName);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
EpubBookInfo? _parseFromArchive(Archive archive, {String? fileName}) {
|
||||
try {
|
||||
// 检查加密(只拒绝真正阻止内容读取的加密,忽略字体混淆等)
|
||||
final encFile = archive.findFile('META-INF/encryption.xml');
|
||||
if (encFile != null) {
|
||||
try {
|
||||
final encContent = utf8.decode(encFile.content as List<int>);
|
||||
final encDoc = XmlDocument.parse(encContent);
|
||||
// 如果有 EncryptedData 且不是字体文件,则拒绝
|
||||
final encryptedData = encDoc.findAllElements('EncryptedData');
|
||||
for (final ed in encryptedData) {
|
||||
final cipherRef = ed.findAllElements('CipherReference').firstOrNull;
|
||||
final uri = cipherRef?.getAttribute('URI') ?? '';
|
||||
// 非字体文件被加密 → 真正的 DRM
|
||||
if (!uri.endsWith('.ttf') &&
|
||||
!uri.endsWith('.otf') &&
|
||||
!uri.endsWith('.woff') &&
|
||||
!uri.endsWith('.woff2')) {
|
||||
debugPrint('[EpubParser] 内容加密的 EPUB,不支持: $uri');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
debugPrint('[EpubParser] 仅字体混淆,继续解析');
|
||||
} catch (e) {
|
||||
debugPrint('[EpubParser] encryption.xml 解析失败,跳过: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final opfPath = _findOpfPath(archive);
|
||||
debugPrint('[EpubParser] OPF 路径: $opfPath');
|
||||
if (opfPath == null) return null;
|
||||
|
||||
final opfFile = archive.findFile(opfPath);
|
||||
debugPrint('[EpubParser] OPF 文件: ${opfFile != null ? '找到' : '未找到'}');
|
||||
if (opfFile == null) return null;
|
||||
|
||||
final opfContent = utf8.decode(opfFile.content as List<int>);
|
||||
debugPrint('[EpubParser] OPF 内容长度: ${opfContent.length}');
|
||||
return _parseOpf(opfContent, opfPath, archive, fileName);
|
||||
} catch (e, stack) {
|
||||
debugPrint('[EpubParser] _parseFromArchive 失败: $e');
|
||||
debugPrint('[EpubParser] $stack');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找 OPF 文件路径
|
||||
String? _findOpfPath(Archive archive) {
|
||||
// 策略1: 解析 container.xml
|
||||
final containerFile = archive.findFile('META-INF/container.xml');
|
||||
if (containerFile != null) {
|
||||
try {
|
||||
final content = utf8.decode(containerFile.content as List<int>);
|
||||
final doc = XmlDocument.parse(content);
|
||||
final rootfile = doc.findAllElements('rootfile').firstOrNull;
|
||||
if (rootfile != null) {
|
||||
final fullPath = rootfile.getAttribute('full-path');
|
||||
if (fullPath != null) return fullPath;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 策略2: 常见路径
|
||||
const commonPaths = [
|
||||
'content.opf',
|
||||
'OEBPS/content.opf',
|
||||
'OPS/content.opf',
|
||||
'EPUB/content.opf',
|
||||
];
|
||||
for (final path in commonPaths) {
|
||||
if (archive.findFile(path) != null) return path;
|
||||
}
|
||||
|
||||
// 策略3: 扫描 .opf 文件
|
||||
for (final file in archive.files) {
|
||||
if (file.name.endsWith('.opf')) return file.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 解析 OPF 文件
|
||||
EpubBookInfo? _parseOpf(
|
||||
String content, String opfPath, Archive archive, String? fileName) {
|
||||
final opfDir =
|
||||
opfPath.contains('/') ? opfPath.substring(0, opfPath.lastIndexOf('/')) : '';
|
||||
|
||||
final doc = XmlDocument.parse(content);
|
||||
final package = doc.rootElement;
|
||||
final version = package.getAttribute('version') ?? '2.0';
|
||||
|
||||
final metadata = package.findElements('metadata').firstOrNull;
|
||||
final manifest = package.findElements('manifest').firstOrNull;
|
||||
final spine = package.findElements('spine').firstOrNull;
|
||||
debugPrint('[EpubParser] metadata=${metadata != null}, manifest=${manifest != null}, spine=${spine != null}');
|
||||
if (metadata == null || manifest == null || spine == null) return null;
|
||||
|
||||
// 解析 manifest (id -> href)
|
||||
final manifestMap = <String, String>{};
|
||||
final manifestProperties = <String, String>{};
|
||||
for (final item in manifest.findElements('item')) {
|
||||
final id = item.getAttribute('id');
|
||||
final href = item.getAttribute('href');
|
||||
final properties = item.getAttribute('properties');
|
||||
if (id != null && href != null) {
|
||||
manifestMap[id] = _resolveRelativePath(opfDir, _normalizePath(href));
|
||||
if (properties != null) manifestProperties[id] = properties;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 metadata
|
||||
final titles = _findByLocalName(metadata, 'title')
|
||||
.map((e) => e.innerText.trim())
|
||||
.where((t) => t.isNotEmpty)
|
||||
.toList();
|
||||
final authors = _findByLocalName(metadata, 'creator')
|
||||
.map((e) => e.innerText.trim())
|
||||
.where((a) => a.isNotEmpty)
|
||||
.toList();
|
||||
final description =
|
||||
_findByLocalName(metadata, 'description').firstOrNull?.innerText.trim();
|
||||
|
||||
// 解析 spine
|
||||
final spineItems = <SpineItem>[];
|
||||
final spineIndexMap = <String, int>{};
|
||||
int index = 0;
|
||||
for (final itemref in spine.findElements('itemref')) {
|
||||
final idref = itemref.getAttribute('idref');
|
||||
final linearAttr = itemref.getAttribute('linear');
|
||||
final isLinear =
|
||||
linearAttr == null || linearAttr.toLowerCase() != 'no';
|
||||
if (idref != null && manifestMap.containsKey(idref)) {
|
||||
final href = manifestMap[idref]!;
|
||||
spineItems.add(SpineItem(
|
||||
index: index,
|
||||
href: href,
|
||||
idref: idref,
|
||||
linear: isLinear,
|
||||
));
|
||||
spineIndexMap[href] = index;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 TOC
|
||||
List<TocEntry> toc = [];
|
||||
|
||||
// EPUB 3 NAV 文档
|
||||
String? navId;
|
||||
for (final entry in manifestProperties.entries) {
|
||||
if (_containsWholeWord(entry.value, 'nav')) {
|
||||
navId = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (navId != null && manifestMap.containsKey(navId)) {
|
||||
final navPath = manifestMap[navId]!;
|
||||
final navFile = archive.findFile(navPath);
|
||||
if (navFile != null) {
|
||||
try {
|
||||
final navContent = utf8.decode(navFile.content as List<int>);
|
||||
final navDir = navPath.contains('/')
|
||||
? navPath.substring(0, navPath.lastIndexOf('/'))
|
||||
: '';
|
||||
toc = _parseNav(navContent, navDir, spineIndexMap);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// EPUB 2 NCX 回退
|
||||
if (toc.isEmpty) {
|
||||
final tocId = spine.getAttribute('toc');
|
||||
if (tocId != null && manifestMap.containsKey(tocId)) {
|
||||
final tocPath = manifestMap[tocId]!;
|
||||
final tocFile = archive.findFile(tocPath);
|
||||
if (tocFile != null) {
|
||||
try {
|
||||
final tocContent = utf8.decode(tocFile.content as List<int>);
|
||||
final ncxDir = tocPath.contains('/')
|
||||
? tocPath.substring(0, tocPath.lastIndexOf('/'))
|
||||
: '';
|
||||
toc = _parseNcx(tocContent, ncxDir, spineIndexMap);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终回退: 从 spine 生成平坦目录
|
||||
if (toc.isEmpty) {
|
||||
int chNum = 1;
|
||||
for (final si in spineItems) {
|
||||
if (!si.linear) continue;
|
||||
toc.add(TocEntry(
|
||||
label: '第 $chNum 章',
|
||||
href: '${si.href}#top',
|
||||
spineIndex: si.index,
|
||||
));
|
||||
chNum++;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测封面
|
||||
String? coverHref = _detectCover(metadata, manifestMap, manifestProperties, archive, opfDir);
|
||||
|
||||
final title = titles.isNotEmpty
|
||||
? titles.first
|
||||
: (fileName ?? '').split('/').last.split('.').first;
|
||||
|
||||
return EpubBookInfo(
|
||||
title: title,
|
||||
author: authors.isNotEmpty ? authors.first : '',
|
||||
authors: authors,
|
||||
description: description,
|
||||
coverHref: coverHref,
|
||||
opfRootPath: opfPath,
|
||||
epubVersion: version,
|
||||
spine: spineItems,
|
||||
toc: toc,
|
||||
);
|
||||
}
|
||||
|
||||
/// 检测封面图片路径(相对于 OPF 目录)
|
||||
String? _detectCover(
|
||||
XmlElement metadata,
|
||||
Map<String, String> manifestMap,
|
||||
Map<String, String> manifestProperties,
|
||||
Archive archive,
|
||||
String opfDir,
|
||||
) {
|
||||
// 策略1: meta name="cover"
|
||||
final coverMeta = metadata
|
||||
.findAllElements('meta')
|
||||
.where((e) => e.getAttribute('name') == 'cover')
|
||||
.firstOrNull;
|
||||
if (coverMeta != null) {
|
||||
final coverId = coverMeta.getAttribute('content');
|
||||
if (coverId != null && manifestMap.containsKey(coverId)) {
|
||||
final href = manifestMap[coverId]!;
|
||||
if (_isImageFile(href)) return href;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略2: manifest 属性包含 cover-image
|
||||
for (final entry in manifestProperties.entries) {
|
||||
if (_containsWholeWord(entry.value, 'cover-image')) {
|
||||
if (manifestMap.containsKey(entry.key)) {
|
||||
final href = manifestMap[entry.key]!;
|
||||
if (_isImageFile(href)) return href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 策略3: 常见文件名
|
||||
for (final key in manifestMap.keys) {
|
||||
final lower = key.toLowerCase();
|
||||
if (lower == 'cover.jpg' ||
|
||||
lower == 'cover.png' ||
|
||||
lower == 'cover.jpeg' ||
|
||||
lower == 'cover.webp') {
|
||||
return manifestMap[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
// 策略4: guide 中的 cover 引用
|
||||
final guideElement = XmlDocument.parse(
|
||||
'<root>${metadata.parent?.toXmlString() ?? ''}</root>')
|
||||
.rootElement
|
||||
.findElements('guide')
|
||||
.firstOrNull;
|
||||
if (guideElement != null) {
|
||||
for (final ref in guideElement.findElements('reference')) {
|
||||
final type = ref.getAttribute('type') ?? '';
|
||||
if (type.toLowerCase() == 'cover') {
|
||||
final href = ref.getAttribute('href');
|
||||
if (href != null) {
|
||||
final resolved = _resolveRelativePath(opfDir, href);
|
||||
if (_isImageFile(resolved)) return resolved;
|
||||
// href 可能指向一个 XHTML 文件,需要从中提取图片
|
||||
final coverFile = archive.findFile(resolved);
|
||||
if (coverFile != null) {
|
||||
try {
|
||||
final html = utf8.decode(coverFile.content as List<int>);
|
||||
final imgSrc = _extractFirstImage(html);
|
||||
if (imgSrc != null) {
|
||||
final hrefDir = resolved.contains('/')
|
||||
? resolved.substring(0, resolved.lastIndexOf('/'))
|
||||
: '';
|
||||
return _resolveRelativePath(hrefDir, imgSrc);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractFirstImage(String html) {
|
||||
final imgReg = RegExp(r'<img[^>]+src="([^">]+)"', caseSensitive: false);
|
||||
final match = imgReg.firstMatch(html);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
/// 解析 EPUB 3 NAV 文档
|
||||
List<TocEntry> _parseNav(
|
||||
String content, String navDir, Map<String, int> spineIndexMap) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(content);
|
||||
final navElement = doc.findAllElements('nav').where((el) {
|
||||
final epubType = el.getAttribute('epub:type') ??
|
||||
el.getAttribute('type') ??
|
||||
'';
|
||||
return _containsWholeWord(epubType, 'toc');
|
||||
}).firstOrNull;
|
||||
if (navElement == null) return [];
|
||||
|
||||
final rootOl = navElement.childElements
|
||||
.where((el) => el.localName == 'ol')
|
||||
.firstOrNull;
|
||||
if (rootOl == null) return [];
|
||||
|
||||
return _parseNavListItems(rootOl.findElements('li'), navDir, spineIndexMap);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<TocEntry> _parseNavListItems(
|
||||
Iterable<XmlElement> items, String baseDir, Map<String, int> spineIndexMap) {
|
||||
final entries = <TocEntry>[];
|
||||
for (final li in items) {
|
||||
final anchor = li.childElements
|
||||
.where((el) => el.localName == 'a' || el.localName == 'span')
|
||||
.firstOrNull;
|
||||
final label =
|
||||
anchor?.innerText.trim().isNotEmpty == true ? anchor!.innerText.trim() : 'Chapter';
|
||||
final hrefValue =
|
||||
anchor?.localName == 'a' ? anchor!.getAttribute('href') : null;
|
||||
|
||||
String href = '';
|
||||
int spineIdx = -1;
|
||||
if (hrefValue != null && hrefValue.trim().isNotEmpty) {
|
||||
final resolved = _resolveRelativePath(baseDir, hrefValue);
|
||||
href = resolved;
|
||||
final pathOnly = href.split('#').first;
|
||||
spineIdx = spineIndexMap[pathOnly] ?? -1;
|
||||
}
|
||||
|
||||
final nestedOl = li.childElements
|
||||
.where((el) => el.localName == 'ol')
|
||||
.firstOrNull;
|
||||
final children = nestedOl != null
|
||||
? _parseNavListItems(nestedOl.findElements('li'), baseDir, spineIndexMap)
|
||||
: <TocEntry>[];
|
||||
|
||||
entries.add(TocEntry(
|
||||
label: label,
|
||||
href: href,
|
||||
spineIndex: spineIdx,
|
||||
children: children,
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// 解析 EPUB 2 NCX 文档
|
||||
List<TocEntry> _parseNcx(
|
||||
String content, String baseDir, Map<String, int> spineIndexMap) {
|
||||
try {
|
||||
final doc = XmlDocument.parse(content);
|
||||
final navMap = doc.findAllElements('navMap').firstOrNull;
|
||||
if (navMap == null) return [];
|
||||
return _parseNavPoints(navMap.findElements('navPoint'), baseDir, spineIndexMap);
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
List<TocEntry> _parseNavPoints(
|
||||
Iterable<XmlElement> navPoints, String baseDir, Map<String, int> spineIndexMap) {
|
||||
final entries = <TocEntry>[];
|
||||
for (final np in navPoints) {
|
||||
final label = np
|
||||
.findElements('navLabel')
|
||||
.firstOrNull
|
||||
?.findElements('text')
|
||||
.firstOrNull
|
||||
?.innerText
|
||||
.trim() ??
|
||||
'Chapter';
|
||||
final src = np.findElements('content').firstOrNull?.getAttribute('src') ?? '';
|
||||
|
||||
final resolved = _resolveRelativePath(baseDir, src);
|
||||
final pathOnly = resolved.split('#').first;
|
||||
final spineIdx = spineIndexMap[pathOnly] ?? -1;
|
||||
|
||||
final children = _parseNavPoints(np.findElements('navPoint'), baseDir, spineIndexMap);
|
||||
|
||||
entries.add(TocEntry(
|
||||
label: label,
|
||||
href: resolved,
|
||||
spineIndex: spineIdx,
|
||||
children: children,
|
||||
));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── 辅助方法 ─────────────────────────────────────────────────
|
||||
|
||||
Iterable<XmlElement> _findByLocalName(XmlElement parent, String name) {
|
||||
return parent.descendantElements.where((e) => e.localName == name);
|
||||
}
|
||||
|
||||
bool _containsWholeWord(String? value, String word) {
|
||||
if (value == null || value.trim().isEmpty) return false;
|
||||
return RegExp('\\b${RegExp.escape(word)}\\b', caseSensitive: false)
|
||||
.hasMatch(value);
|
||||
}
|
||||
|
||||
String _resolveRelativePath(String baseDir, String relativePath) {
|
||||
if (baseDir.isEmpty) return relativePath;
|
||||
final baseUri = Uri.parse(baseDir.endsWith('/') ? baseDir : '$baseDir/');
|
||||
final resolved = baseUri.resolve(relativePath);
|
||||
String result = resolved.toString();
|
||||
if (result.startsWith('/')) result = result.substring(1);
|
||||
return Uri.decodeFull(result);
|
||||
}
|
||||
|
||||
String _normalizePath(String path) {
|
||||
path = path.trim();
|
||||
while (path.startsWith('/')) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
while (path.endsWith('/')) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
path = path.replaceAll(RegExp(r'/+'), '/');
|
||||
return path;
|
||||
}
|
||||
|
||||
bool _isImageFile(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
return lower.endsWith('.jpg') ||
|
||||
lower.endsWith('.jpeg') ||
|
||||
lower.endsWith('.png') ||
|
||||
lower.endsWith('.webp');
|
||||
}
|
||||
}
|
||||
155
lib/services/epub/epub_service.dart
Normal file
155
lib/services/epub/epub_service.dart
Normal file
@@ -0,0 +1,155 @@
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'epub_parser.dart';
|
||||
import 'reader_dao.dart';
|
||||
import 'reader_models.dart';
|
||||
|
||||
/// EPUB 服务层 - 管理导入、解压、删除
|
||||
class EpubService {
|
||||
final ReaderDao _dao = ReaderDao();
|
||||
final EpubParser _parser = EpubParser();
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// 导入 EPUB 文件
|
||||
/// 返回 {'bookId': ..., 'title': ...} 或 null(解析失败)
|
||||
Future<Map<String, dynamic>?> importBook(String sourcePath) async {
|
||||
final bookId = _uuid.v4();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
final fileName = p.basename(sourcePath);
|
||||
|
||||
// 复制 EPUB 到永久存储(FilePicker 临时文件会被清理)
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
|
||||
if (!await bookDir.exists()) await bookDir.create(recursive: true);
|
||||
final permanentPath = p.join(bookDir.path, 'book.epub');
|
||||
await File(sourcePath).copy(permanentPath);
|
||||
|
||||
// 从永久副本解析
|
||||
final info = await _parser.parseFromFile(
|
||||
permanentPath,
|
||||
fileName: fileName,
|
||||
);
|
||||
if (info == null) return null;
|
||||
|
||||
// 解压到临时目录
|
||||
final extractDir = await getExtractDir(bookId);
|
||||
await _extractEpub(permanentPath, extractDir);
|
||||
|
||||
// 提取封面
|
||||
String? coverPath;
|
||||
if (info.coverHref != null) {
|
||||
coverPath = await _extractCover(info, extractDir, bookId);
|
||||
}
|
||||
|
||||
// 写入数据库(file_path 存永久路径)
|
||||
await _dao.insertReaderBook({
|
||||
'id': bookId,
|
||||
'title': info.title,
|
||||
'author': info.author,
|
||||
'cover_path': coverPath,
|
||||
'file_path': permanentPath,
|
||||
'file_name': fileName,
|
||||
'file_extension': 'epub',
|
||||
'last_read_cfi': '',
|
||||
'reading_percentage': 0.0,
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
'is_deleted': 0,
|
||||
});
|
||||
|
||||
return {'bookId': bookId, 'title': info.title};
|
||||
}
|
||||
|
||||
/// 解压 EPUB 到目标目录
|
||||
Future<void> _extractEpub(String sourcePath, String targetDir) async {
|
||||
final dir = Directory(targetDir);
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
await dir.create(recursive: true);
|
||||
|
||||
final bytes = await File(sourcePath).readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
|
||||
for (final file in archive) {
|
||||
final filePath = p.join(targetDir, file.name);
|
||||
if (file.isFile) {
|
||||
final outFile = File(filePath);
|
||||
await outFile.parent.create(recursive: true);
|
||||
await outFile.writeAsBytes(file.content as List<int>);
|
||||
} else {
|
||||
await Directory(filePath).create(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 提取封面图片
|
||||
Future<String?> _extractCover(
|
||||
EpubBookInfo info, String extractDir, String bookId) async {
|
||||
try {
|
||||
final opfDir = info.opfRootPath.contains('/')
|
||||
? info.opfRootPath.substring(0, info.opfRootPath.lastIndexOf('/'))
|
||||
: '';
|
||||
final coverRelPath = opfDir.isEmpty
|
||||
? info.coverHref!
|
||||
: '$opfDir/${info.coverHref!}';
|
||||
final coverFile = File(p.join(extractDir, coverRelPath));
|
||||
if (!await coverFile.exists()) return null;
|
||||
|
||||
// 保存到 epub_books/{bookId}/ 目录下
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final coverDir = p.join(appDir.path, 'epub_books', bookId);
|
||||
await Directory(coverDir).create(recursive: true);
|
||||
final ext = p.extension(coverFile.path).toLowerCase();
|
||||
final destPath = p.join(coverDir, 'cover$ext');
|
||||
await coverFile.copy(destPath);
|
||||
return destPath;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取解压目录
|
||||
Future<String> getExtractDir(String bookId) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
return p.join(tempDir.path, 'epub', bookId);
|
||||
}
|
||||
|
||||
/// 确保已解压(如果临时目录被清理则重新解压)
|
||||
/// 返回解压目录路径,失败返回 null
|
||||
Future<String?> ensureExtracted(String bookId, String filePath) async {
|
||||
final extractDir = await getExtractDir(bookId);
|
||||
final dir = Directory(extractDir);
|
||||
|
||||
if (await dir.exists()) {
|
||||
final files = dir.listSync();
|
||||
if (files.isNotEmpty) return extractDir;
|
||||
}
|
||||
|
||||
// 重新解压
|
||||
if (!await File(filePath).exists()) return null;
|
||||
await _extractEpub(filePath, extractDir);
|
||||
return extractDir;
|
||||
}
|
||||
|
||||
/// 删除书籍
|
||||
Future<void> deleteBook(String bookId) async {
|
||||
// 清理解压目录
|
||||
try {
|
||||
final extractDir = await getExtractDir(bookId);
|
||||
final dir = Directory(extractDir);
|
||||
if (await dir.exists()) await dir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
|
||||
// 清理 epub_books/{bookId}/ 目录(epub + 封面)
|
||||
try {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final bookDir = Directory(p.join(appDir.path, 'epub_books', bookId));
|
||||
if (await bookDir.exists()) await bookDir.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
|
||||
// 软删除数据库记录
|
||||
await _dao.deleteReaderBook(bookId);
|
||||
}
|
||||
}
|
||||
102
lib/services/epub/epub_stream_service.dart
Normal file
102
lib/services/epub/epub_stream_service.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class EpubStreamService {
|
||||
String? _currentBookPath;
|
||||
String? _pendingBookPath;
|
||||
Future<void>? _openBookFuture;
|
||||
|
||||
/// Cached decoded archive for the current book.
|
||||
Archive? _cachedArchive;
|
||||
|
||||
Future<void> warmUp() async {}
|
||||
|
||||
Future<void> openBook(String epubPath) {
|
||||
if (_currentBookPath == epubPath && _cachedArchive != null) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
if (_pendingBookPath == epubPath && _openBookFuture != null) {
|
||||
return _openBookFuture!;
|
||||
}
|
||||
|
||||
_pendingBookPath = epubPath;
|
||||
_openBookFuture = _doOpenBook(epubPath);
|
||||
return _openBookFuture!;
|
||||
}
|
||||
|
||||
Future<void> _doOpenBook(String epubPath) async {
|
||||
try {
|
||||
final bytes = await File(epubPath).readAsBytes();
|
||||
_cachedArchive = ZipDecoder().decodeBytes(bytes);
|
||||
_currentBookPath = epubPath;
|
||||
} catch (e) {
|
||||
_currentBookPath = null;
|
||||
_cachedArchive = null;
|
||||
rethrow;
|
||||
} finally {
|
||||
if (_pendingBookPath == epubPath) {
|
||||
_pendingBookPath = null;
|
||||
_openBookFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a single file from the currently open EPUB archive.
|
||||
/// Returns the file bytes, or null if not found / no book loaded.
|
||||
Future<Uint8List?> readFileFromEpub({
|
||||
required String targetFilePath,
|
||||
String? epubPath,
|
||||
}) async {
|
||||
if (epubPath != null && epubPath != _currentBookPath) {
|
||||
await openBook(epubPath);
|
||||
}
|
||||
|
||||
if (_currentBookPath == null || _cachedArchive == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = _cachedArchive!.findFile(targetFilePath);
|
||||
if (file == null || file.content == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.content is Uint8List
|
||||
? file.content as Uint8List
|
||||
: Uint8List.fromList(file.content as List<int>);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cachedArchive = null;
|
||||
_currentBookPath = null;
|
||||
_pendingBookPath = null;
|
||||
_openBookFuture = null;
|
||||
}
|
||||
|
||||
String getMimeType(String filePath) {
|
||||
final ext = p.extension(filePath).toLowerCase().replaceAll('.', '');
|
||||
return _mimeTypeMap[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
static const _mimeTypeMap = {
|
||||
'html': 'text/html',
|
||||
'htm': 'text/html',
|
||||
'xhtml': 'application/xhtml+xml',
|
||||
'xml': 'application/xml',
|
||||
'css': 'text/css',
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'svg': 'image/svg+xml',
|
||||
'webp': 'image/webp',
|
||||
'ttf': 'font/ttf',
|
||||
'otf': 'font/otf',
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
'js': 'application/javascript',
|
||||
};
|
||||
}
|
||||
140
lib/services/epub/epub_theme.dart
Normal file
140
lib/services/epub/epub_theme.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'reader_scripts.dart';
|
||||
|
||||
/// 阅读器主题预设
|
||||
class ReaderThemePreset {
|
||||
final String name;
|
||||
final Color surface;
|
||||
final Color onSurface;
|
||||
final bool isDark;
|
||||
|
||||
const ReaderThemePreset({
|
||||
required this.name,
|
||||
required this.surface,
|
||||
required this.onSurface,
|
||||
this.isDark = false,
|
||||
});
|
||||
}
|
||||
|
||||
class ReaderThemePresets {
|
||||
static const List<ReaderThemePreset> presets = [
|
||||
ReaderThemePreset(name: '跟随App', surface: Colors.white, onSurface: Colors.black),
|
||||
ReaderThemePreset(name: '纯白', surface: Color(0xFFFFFFFF), onSurface: Color(0xFF1A1A1A)),
|
||||
ReaderThemePreset(name: '护眼', surface: Color(0xFFF4ECD8), onSurface: Color(0xFF5B4636)),
|
||||
ReaderThemePreset(name: '抹茶', surface: Color(0xFFF6FBF5), onSurface: Color(0xFF2E3E2E)),
|
||||
ReaderThemePreset(name: '樱花', surface: Color(0xFFFFF8F8), onSurface: Color(0xFF4A2030)),
|
||||
ReaderThemePreset(name: '午夜蓝', surface: Color(0xFFF7F9FC), onSurface: Color(0xFF1A2A3A)),
|
||||
ReaderThemePreset(name: '深色', surface: Color(0xFF191919), onSurface: Color(0xFFD4D4D4), isDark: true),
|
||||
ReaderThemePreset(name: '深色护眼', surface: Color(0xFF1C1A18), onSurface: Color(0xFFC8B8A0), isDark: true),
|
||||
ReaderThemePreset(name: '咖啡', surface: Color(0xFFFCF8F3), onSurface: Color(0xFF3E2E1E)),
|
||||
];
|
||||
}
|
||||
|
||||
class EpubTheme {
|
||||
final double zoom;
|
||||
final bool shouldOverrideTextColor;
|
||||
final ColorScheme colorScheme;
|
||||
final Color? overridePrimaryColor;
|
||||
final EdgeInsets padding;
|
||||
|
||||
/// File name (with extension) of the custom font, or null for epub default.
|
||||
final String? fontFileName;
|
||||
|
||||
/// When true, force the custom font on top of the epub's own font rules.
|
||||
final bool overrideFontFamily;
|
||||
|
||||
EpubTheme({
|
||||
required this.zoom,
|
||||
required this.shouldOverrideTextColor,
|
||||
required this.colorScheme,
|
||||
this.overridePrimaryColor,
|
||||
required this.padding,
|
||||
this.fontFileName,
|
||||
this.overrideFontFamily = false,
|
||||
});
|
||||
|
||||
bool get isDark => colorScheme.brightness == Brightness.dark;
|
||||
|
||||
Color get surfaceColor => colorScheme.surface;
|
||||
|
||||
EpubTheme copyWith({
|
||||
double? zoom,
|
||||
bool? shouldOverrideTextColor,
|
||||
ColorScheme? colorScheme,
|
||||
Color? overridePrimaryColor,
|
||||
EdgeInsets? padding,
|
||||
Object? fontFileName = _kUnset,
|
||||
bool? overrideFontFamily,
|
||||
}) {
|
||||
return EpubTheme(
|
||||
zoom: zoom ?? this.zoom,
|
||||
shouldOverrideTextColor:
|
||||
shouldOverrideTextColor ?? this.shouldOverrideTextColor,
|
||||
colorScheme: colorScheme ?? this.colorScheme,
|
||||
overridePrimaryColor: overridePrimaryColor ?? this.overridePrimaryColor,
|
||||
padding: padding ?? this.padding,
|
||||
fontFileName: identical(fontFileName, _kUnset)
|
||||
? this.fontFileName
|
||||
: fontFileName as String?,
|
||||
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
static const Object _kUnset = Object();
|
||||
|
||||
Map<String, dynamic> toThemeMap() {
|
||||
return {
|
||||
'padding': {'top': padding.top, 'left': padding.left},
|
||||
'theme': {
|
||||
'zoom': zoom,
|
||||
'shouldOverrideTextColor': shouldOverrideTextColor,
|
||||
|
||||
'primaryColor': overridePrimaryColor != null
|
||||
? colorToMap(overridePrimaryColor!)
|
||||
: colorToMap(colorScheme.primary),
|
||||
'onPrimaryColor': colorToMap(colorScheme.onPrimary),
|
||||
'secondaryColor': colorToMap(colorScheme.secondary),
|
||||
'onSecondaryColor': colorToMap(colorScheme.onSecondary),
|
||||
'errorColor': colorToMap(colorScheme.error),
|
||||
'onErrorColor': colorToMap(colorScheme.onError),
|
||||
'surfaceColor': colorToMap(colorScheme.surface),
|
||||
'onSurfaceColor': colorToMap(colorScheme.onSurface),
|
||||
'primaryContainerColor': colorToMap(colorScheme.primaryContainer),
|
||||
'onSurfaceVariantColor': colorToMap(colorScheme.onSurfaceVariant),
|
||||
'outlineVariantColor': colorToMap(colorScheme.outlineVariant),
|
||||
'surfaceContainerColor': colorToMap(colorScheme.surfaceContainer),
|
||||
'surfaceContainerHighColor': colorToMap(
|
||||
colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
|
||||
'fontFileName': fontFileName,
|
||||
'overrideFontFamily': overrideFontFamily,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is EpubTheme &&
|
||||
other.zoom == zoom &&
|
||||
other.shouldOverrideTextColor == shouldOverrideTextColor &&
|
||||
other.colorScheme == colorScheme &&
|
||||
other.overridePrimaryColor == overridePrimaryColor &&
|
||||
other.padding == padding &&
|
||||
other.fontFileName == fontFileName &&
|
||||
other.overrideFontFamily == overrideFontFamily;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
zoom,
|
||||
shouldOverrideTextColor,
|
||||
colorScheme,
|
||||
overridePrimaryColor,
|
||||
padding,
|
||||
fontFileName,
|
||||
overrideFontFamily,
|
||||
);
|
||||
}
|
||||
258
lib/services/epub/epub_webview_handler.dart
Normal file
258
lib/services/epub/epub_webview_handler.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'epub_stream_service.dart';
|
||||
|
||||
/// Simple file reference with path and optional anchor.
|
||||
class Href {
|
||||
final String path;
|
||||
final String anchor;
|
||||
|
||||
const Href({required this.path, this.anchor = 'top'});
|
||||
|
||||
@override
|
||||
String toString() => '$path#$anchor';
|
||||
}
|
||||
|
||||
/// WebView request handler for streaming EPUB content.
|
||||
/// Intercepts requests to virtual domain and serves files from compressed EPUB.
|
||||
class EpubWebViewHandler {
|
||||
final EpubStreamService _streamService;
|
||||
|
||||
/// Virtual domain for EPUB content.
|
||||
/// Format: epub://localhost/book/{fileHash}/{filePath}
|
||||
static const String virtualDomain = 'localhost';
|
||||
static const String virtualScheme = 'epub';
|
||||
static const _headers = {'Cache-Control': 'public, max-age=31536000'};
|
||||
|
||||
EpubWebViewHandler({required EpubStreamService streamService})
|
||||
: _streamService = streamService;
|
||||
|
||||
/// Cached documents directory path.
|
||||
static String? _documentsPath;
|
||||
|
||||
static Future<String> getDocumentsPath() async {
|
||||
if (_documentsPath != null) return _documentsPath!;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_documentsPath = '${dir.path}/';
|
||||
return _documentsPath!;
|
||||
}
|
||||
|
||||
/// Create WebView resource request handler.
|
||||
/// This should be set as the shouldInterceptRequest callback.
|
||||
Future<WebResourceResponse?> handleRequest({
|
||||
required String epubPath,
|
||||
required String fileHash,
|
||||
required WebUri requestUrl,
|
||||
}) async {
|
||||
try {
|
||||
// Serve user-imported fonts.
|
||||
if (isFontRequest(requestUrl)) {
|
||||
final fontResult = await _readFontFile(requestUrl);
|
||||
if (fontResult == null) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 404,
|
||||
reasonPhrase: 'Not Found',
|
||||
data: Uint8List.fromList('Font not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
return WebResourceResponse(
|
||||
contentType: fontResult.$2,
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
data: fontResult.$1,
|
||||
headers: _headers,
|
||||
);
|
||||
}
|
||||
|
||||
// Read file from EPUB
|
||||
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
|
||||
|
||||
if (result == null) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 404,
|
||||
reasonPhrase: 'Not Found',
|
||||
data: Uint8List.fromList('File not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
|
||||
return WebResourceResponse(
|
||||
contentType: result.$2,
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
data: result.$1,
|
||||
headers: _headers,
|
||||
);
|
||||
} catch (e) {
|
||||
return WebResourceResponse(
|
||||
statusCode: 500,
|
||||
reasonPhrase: 'Internal Server Error',
|
||||
data: Uint8List.fromList('Error: $e'.codeUnits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<CustomSchemeResponse?> handleRequestWithCustomScheme({
|
||||
required String epubPath,
|
||||
required String fileHash,
|
||||
required WebUri requestUrl,
|
||||
}) async {
|
||||
try {
|
||||
// Serve user-imported fonts.
|
||||
if (isFontRequest(requestUrl)) {
|
||||
final fontResult = await _readFontFile(requestUrl);
|
||||
if (fontResult == null) {
|
||||
return CustomSchemeResponse(
|
||||
contentType: 'text/plain',
|
||||
data: Uint8List.fromList('Font not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
return CustomSchemeResponse(
|
||||
contentType: fontResult.$2,
|
||||
data: fontResult.$1,
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _readFileFromEpub(epubPath, fileHash, requestUrl);
|
||||
|
||||
if (result == null) {
|
||||
return CustomSchemeResponse(
|
||||
contentType: 'text/plain',
|
||||
data: Uint8List.fromList('File not found'.codeUnits),
|
||||
);
|
||||
}
|
||||
|
||||
return CustomSchemeResponse(
|
||||
contentType: result.$2,
|
||||
data: result.$1,
|
||||
);
|
||||
} catch (e) {
|
||||
return CustomSchemeResponse(
|
||||
contentType: 'text/plain',
|
||||
data: Uint8List.fromList('Error reading file: $e'.codeUnits),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a file from an EPUB.
|
||||
/// Returns (data, mimeType) or null on failure.
|
||||
Future<(Uint8List, String)?> _readFileFromEpub(
|
||||
String epubPath,
|
||||
String fileHash,
|
||||
WebUri requestUrl,
|
||||
) async {
|
||||
final prefix = "/book/$fileHash/";
|
||||
if (!requestUrl.path.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final decodedPath = Uri.decodeFull(requestUrl.path);
|
||||
final relativePath = decodedPath.substring(prefix.length);
|
||||
final fileRelativePath = relativePath.split('#')[0];
|
||||
|
||||
final data = await _streamService.readFileFromEpub(
|
||||
epubPath: epubPath,
|
||||
targetFilePath: fileRelativePath,
|
||||
);
|
||||
|
||||
if (data == null) return null;
|
||||
|
||||
final mimeType = _streamService.getMimeType(fileRelativePath);
|
||||
return (data, mimeType);
|
||||
}
|
||||
|
||||
/// Reads a font file from the app's fonts directory.
|
||||
/// URL format: epub://localhost/fonts/{fileName}
|
||||
Future<(Uint8List, String)?> _readFontFile(WebUri requestUrl) async {
|
||||
const prefix = '/fonts/';
|
||||
if (!requestUrl.path.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
final fileName = Uri.decodeComponent(
|
||||
requestUrl.path.substring(prefix.length),
|
||||
);
|
||||
if (fileName.isEmpty || fileName.contains('/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final documentsPath = await getDocumentsPath();
|
||||
final filePath = '${documentsPath}fonts/$fileName';
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
final bytes = await file.readAsBytes();
|
||||
final ext = fileName.toLowerCase().split('.').last;
|
||||
final mimeType = _fontMimeTypes[ext] ?? 'application/octet-stream';
|
||||
return (bytes, mimeType);
|
||||
}
|
||||
|
||||
static const _fontMimeTypes = {
|
||||
'ttf': 'font/ttf',
|
||||
'otf': 'font/otf',
|
||||
'woff': 'font/woff',
|
||||
'woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
/// Generate base URL for a chapter.
|
||||
/// This URL should be used as the baseUrl parameter when loading HTML.
|
||||
static String getBaseUrl() {
|
||||
return '$virtualScheme://$virtualDomain/book/index.html';
|
||||
}
|
||||
|
||||
/// Generate full URL for a specific file.
|
||||
static String getFileUrl(String fileHash, Href href) {
|
||||
final url =
|
||||
'$virtualScheme://$virtualDomain/book/$fileHash/${href.path}${'#${href.anchor}'}';
|
||||
return Uri.encodeFull(url);
|
||||
}
|
||||
|
||||
/// Generate URL for a user-imported font file.
|
||||
/// Format: epub://localhost/fonts/{fileName}
|
||||
static String getFontUrl(String fileName) {
|
||||
return '$virtualScheme://$virtualDomain/fonts/$fileName';
|
||||
}
|
||||
|
||||
/// Check if a request is for an EPUB file.
|
||||
static bool isEpubRequest(WebUri requestUrl) {
|
||||
return requestUrl.scheme == virtualScheme &&
|
||||
requestUrl.host == virtualDomain &&
|
||||
requestUrl.path.startsWith('/book/');
|
||||
}
|
||||
|
||||
/// Resolve image bytes from an EPUB for the image viewer.
|
||||
/// The imageUrl may be a virtual epub:// URL or a relative path.
|
||||
Future<Uint8List?> resolveImageFromEpub({
|
||||
required String epubPath,
|
||||
required String imageUrl,
|
||||
required String fileHash,
|
||||
}) async {
|
||||
try {
|
||||
String relativePath;
|
||||
if (imageUrl.startsWith(virtualScheme)) {
|
||||
final uri = Uri.parse(imageUrl);
|
||||
final prefix = '/book/$fileHash/';
|
||||
if (!uri.path.startsWith(prefix)) return null;
|
||||
relativePath = Uri.decodeFull(uri.path).substring(prefix.length);
|
||||
} else {
|
||||
relativePath = imageUrl;
|
||||
}
|
||||
relativePath = relativePath.split('#')[0];
|
||||
|
||||
return await _streamService.readFileFromEpub(
|
||||
epubPath: epubPath,
|
||||
targetFilePath: relativePath,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a request is for a user-imported font.
|
||||
static bool isFontRequest(WebUri requestUrl) {
|
||||
return requestUrl.scheme == virtualScheme &&
|
||||
requestUrl.host == virtualDomain &&
|
||||
requestUrl.path.startsWith('/fonts/');
|
||||
}
|
||||
}
|
||||
102
lib/services/epub/reader_scripts.dart
Normal file
102
lib/services/epub/reader_scripts.dart
Normal file
File diff suppressed because one or more lines are too long
217
lib/services/epub/reader_settings.dart
Normal file
217
lib/services/epub/reader_settings.dart
Normal file
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'epub_theme.dart';
|
||||
|
||||
/// Controls how the reader handles external link taps.
|
||||
enum ReaderLinkHandling { ask, always, never }
|
||||
|
||||
/// Controls the page-turning animation style.
|
||||
enum ReaderPageAnimation { none, slide }
|
||||
|
||||
class ReaderSettings {
|
||||
final double zoom;
|
||||
final bool followAppTheme;
|
||||
final double marginTop;
|
||||
final double marginBottom;
|
||||
final double marginLeft;
|
||||
final double marginRight;
|
||||
final ReaderLinkHandling linkHandling;
|
||||
final ReaderPageAnimation pageAnimation;
|
||||
|
||||
/// File name (with extension) of the user-imported font to use, or null to
|
||||
/// use the epub's own fonts.
|
||||
final String? fontFileName;
|
||||
|
||||
/// When true the custom font overrides the epub's own font-family rules.
|
||||
final bool overrideFontFamily;
|
||||
|
||||
/// When true, volume up/down keys turn pages in the reader.
|
||||
final bool volumeKeyTurnsPage;
|
||||
|
||||
/// Reader theme preset index (0 = follow app, 1-8 = presets, 9 = custom).
|
||||
final int themeIndex;
|
||||
|
||||
/// Custom background color (ARGB int), used when themeIndex == 9.
|
||||
final int customBgColor;
|
||||
|
||||
/// Custom text color (ARGB int), used when themeIndex == 9.
|
||||
final int customTextColor;
|
||||
|
||||
const ReaderSettings({
|
||||
this.zoom = 1.0,
|
||||
this.followAppTheme = true,
|
||||
this.marginTop = 16.0,
|
||||
this.marginBottom = 16.0,
|
||||
this.marginLeft = 16.0,
|
||||
this.marginRight = 16.0,
|
||||
this.linkHandling = ReaderLinkHandling.ask,
|
||||
this.pageAnimation = ReaderPageAnimation.slide,
|
||||
this.fontFileName,
|
||||
this.overrideFontFamily = false,
|
||||
this.volumeKeyTurnsPage = false,
|
||||
this.themeIndex = 0,
|
||||
this.customBgColor = 0xFFFFFFFF,
|
||||
this.customTextColor = 0xFF1A1A1A,
|
||||
});
|
||||
|
||||
// Sentinel: lets copyWith(fontFileName: null) mean "set to null" rather than
|
||||
// "leave unchanged". Used only for the nullable fontFileName field.
|
||||
static const Object _kUnset = Object();
|
||||
|
||||
ReaderSettings copyWith({
|
||||
double? zoom,
|
||||
bool? followAppTheme,
|
||||
double? marginTop,
|
||||
double? marginBottom,
|
||||
double? marginLeft,
|
||||
double? marginRight,
|
||||
ReaderLinkHandling? linkHandling,
|
||||
ReaderPageAnimation? pageAnimation,
|
||||
Object? fontFileName = _kUnset,
|
||||
bool? overrideFontFamily,
|
||||
bool? volumeKeyTurnsPage,
|
||||
int? themeIndex,
|
||||
int? customBgColor,
|
||||
int? customTextColor,
|
||||
}) {
|
||||
return ReaderSettings(
|
||||
zoom: zoom ?? this.zoom,
|
||||
followAppTheme: followAppTheme ?? this.followAppTheme,
|
||||
marginTop: marginTop ?? this.marginTop,
|
||||
marginBottom: marginBottom ?? this.marginBottom,
|
||||
marginLeft: marginLeft ?? this.marginLeft,
|
||||
marginRight: marginRight ?? this.marginRight,
|
||||
linkHandling: linkHandling ?? this.linkHandling,
|
||||
pageAnimation: pageAnimation ?? this.pageAnimation,
|
||||
fontFileName: identical(fontFileName, _kUnset)
|
||||
? this.fontFileName
|
||||
: fontFileName as String?,
|
||||
overrideFontFamily: overrideFontFamily ?? this.overrideFontFamily,
|
||||
volumeKeyTurnsPage: volumeKeyTurnsPage ?? this.volumeKeyTurnsPage,
|
||||
themeIndex: themeIndex ?? this.themeIndex,
|
||||
customBgColor: customBgColor ?? this.customBgColor,
|
||||
customTextColor: customTextColor ?? this.customTextColor,
|
||||
);
|
||||
}
|
||||
|
||||
EpubTheme toEpubTheme(BuildContext context) {
|
||||
ColorScheme colorScheme;
|
||||
bool shouldOverride = true;
|
||||
|
||||
if (themeIndex == 0) {
|
||||
// 跟随 App 主题
|
||||
colorScheme = Theme.of(context).colorScheme;
|
||||
} else {
|
||||
Color bg;
|
||||
Color text;
|
||||
bool isDark;
|
||||
|
||||
if (themeIndex == 9) {
|
||||
// 自定义颜色
|
||||
bg = Color(customBgColor);
|
||||
text = Color(customTextColor);
|
||||
isDark = ThemeData.estimateBrightnessForColor(bg) == Brightness.dark;
|
||||
} else if (themeIndex >= 1 &&
|
||||
themeIndex <= ReaderThemePresets.presets.length) {
|
||||
final preset = ReaderThemePresets.presets[themeIndex];
|
||||
bg = preset.surface;
|
||||
text = preset.onSurface;
|
||||
isDark = preset.isDark;
|
||||
} else {
|
||||
colorScheme = Theme.of(context).colorScheme;
|
||||
return EpubTheme(
|
||||
zoom: zoom,
|
||||
shouldOverrideTextColor: true,
|
||||
colorScheme: colorScheme,
|
||||
padding: EdgeInsets.only(
|
||||
top: marginTop, bottom: marginBottom,
|
||||
left: marginLeft, right: marginRight,
|
||||
),
|
||||
fontFileName: fontFileName,
|
||||
overrideFontFamily: overrideFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
colorScheme = ColorScheme(
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
primary: text,
|
||||
onPrimary: bg,
|
||||
secondary: text,
|
||||
onSecondary: bg,
|
||||
error: const Color(0xFFDC2626),
|
||||
onError: bg,
|
||||
surface: bg,
|
||||
onSurface: text,
|
||||
surfaceContainerHighest: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
|
||||
surfaceContainerHigh: isDark ? const Color(0xFF222222) : const Color(0xFFFAFAFA),
|
||||
surfaceContainer: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
|
||||
surfaceContainerLow: isDark ? const Color(0xFF1A1A1A) : const Color(0xFFFAFAFA),
|
||||
outline: isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC),
|
||||
outlineVariant: isDark ? const Color(0xFF333333) : const Color(0xFFE5E5E5),
|
||||
onSurfaceVariant: isDark ? const Color(0xFFAAAAAA) : const Color(0xFF666666),
|
||||
primaryContainer: isDark ? const Color(0xFF2A2A2A) : const Color(0xFFF0F0F0),
|
||||
);
|
||||
}
|
||||
|
||||
return EpubTheme(
|
||||
zoom: zoom,
|
||||
shouldOverrideTextColor: shouldOverride,
|
||||
colorScheme: colorScheme,
|
||||
padding: EdgeInsets.only(
|
||||
top: marginTop, bottom: marginBottom,
|
||||
left: marginLeft, right: marginRight,
|
||||
),
|
||||
fontFileName: fontFileName,
|
||||
overrideFontFamily: overrideFontFamily,
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Persistence ====================
|
||||
|
||||
static const _kPrefix = 'reader_';
|
||||
|
||||
Future<void> save() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble('${_kPrefix}zoom', zoom);
|
||||
await prefs.setBool('${_kPrefix}followAppTheme', followAppTheme);
|
||||
await prefs.setDouble('${_kPrefix}marginTop', marginTop);
|
||||
await prefs.setDouble('${_kPrefix}marginBottom', marginBottom);
|
||||
await prefs.setDouble('${_kPrefix}marginLeft', marginLeft);
|
||||
await prefs.setDouble('${_kPrefix}marginRight', marginRight);
|
||||
await prefs.setInt('${_kPrefix}linkHandling', linkHandling.index);
|
||||
await prefs.setInt('${_kPrefix}pageAnimation', pageAnimation.index);
|
||||
if (fontFileName != null) {
|
||||
await prefs.setString('${_kPrefix}fontFileName', fontFileName!);
|
||||
} else {
|
||||
await prefs.remove('${_kPrefix}fontFileName');
|
||||
}
|
||||
await prefs.setBool('${_kPrefix}overrideFontFamily', overrideFontFamily);
|
||||
await prefs.setBool('${_kPrefix}volumeKeyTurnsPage', volumeKeyTurnsPage);
|
||||
await prefs.setInt('${_kPrefix}themeIndex', themeIndex);
|
||||
await prefs.setInt('${_kPrefix}customBgColor', customBgColor);
|
||||
await prefs.setInt('${_kPrefix}customTextColor', customTextColor);
|
||||
}
|
||||
|
||||
static Future<ReaderSettings> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return ReaderSettings(
|
||||
zoom: prefs.getDouble('${_kPrefix}zoom') ?? 1.0,
|
||||
followAppTheme: prefs.getBool('${_kPrefix}followAppTheme') ?? true,
|
||||
marginTop: prefs.getDouble('${_kPrefix}marginTop') ?? 16.0,
|
||||
marginBottom: prefs.getDouble('${_kPrefix}marginBottom') ?? 16.0,
|
||||
marginLeft: prefs.getDouble('${_kPrefix}marginLeft') ?? 16.0,
|
||||
marginRight: prefs.getDouble('${_kPrefix}marginRight') ?? 16.0,
|
||||
linkHandling: ReaderLinkHandling.values[
|
||||
prefs.getInt('${_kPrefix}linkHandling') ??
|
||||
ReaderLinkHandling.ask.index],
|
||||
pageAnimation: ReaderPageAnimation.values[
|
||||
prefs.getInt('${_kPrefix}pageAnimation') ??
|
||||
ReaderPageAnimation.slide.index],
|
||||
fontFileName: prefs.getString('${_kPrefix}fontFileName'),
|
||||
overrideFontFamily:
|
||||
prefs.getBool('${_kPrefix}overrideFontFamily') ?? false,
|
||||
volumeKeyTurnsPage:
|
||||
prefs.getBool('${_kPrefix}volumeKeyTurnsPage') ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
87
lib/services/epub/web/reader_api.dart
Normal file
87
lib/services/epub/web/reader_api.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'webview_bridge.dart';
|
||||
|
||||
/// Typed Dart mirror of the TypeScript `ReaderApi` interface
|
||||
/// (`web_assets/controller.js/api.ts`).
|
||||
///
|
||||
/// Every public method corresponds 1-to-1 with its TypeScript counterpart.
|
||||
/// The token parameter is managed internally by [WebViewBridge] — callers
|
||||
/// never touch raw token integers through this class.
|
||||
///
|
||||
/// Methods that return `Future<int>` fire the JS call and return a token the
|
||||
/// caller can later pass to [WebViewBridge.waitForEvent] / [waitForEvents]
|
||||
/// when it wants to batch-await multiple operations together.
|
||||
///
|
||||
/// Methods that return `Future<void>` fire the JS call and await its
|
||||
/// completion before returning.
|
||||
class ReaderApi {
|
||||
final WebViewBridge _bridge;
|
||||
|
||||
ReaderApi(this._bridge);
|
||||
|
||||
// ─── Token-based (deferred await) ──────────────────────────────────
|
||||
|
||||
/// Loads [url] into the iframe identified by [slot].
|
||||
/// [anchors] should be a JSON-encoded list: `'["id1","id2"]'`.
|
||||
Future<int> loadFrame(
|
||||
String slot,
|
||||
String url,
|
||||
String anchors,
|
||||
String properties,
|
||||
) => _bridge.call(
|
||||
(t) => "window.api.loadFrame($t, '$slot', '$url', $anchors, $properties)",
|
||||
);
|
||||
|
||||
/// Scrolls [slot]'s iframe to [pageIndex] without immediately awaiting.
|
||||
Future<int> jumpToPageFor(String slot, int pageIndex) =>
|
||||
_bridge.call((t) => "window.api.jumpToPageFor($t, '$slot', $pageIndex)");
|
||||
|
||||
/// Scrolls [slot]'s iframe to its last page without immediately awaiting.
|
||||
Future<int> jumpToLastPageOfFrame(String slot) =>
|
||||
_bridge.call((t) => "window.api.jumpToLastPageOfFrame($t, '$slot')");
|
||||
|
||||
/// Rotates the iframe triple in [direction] (`'next'` or `'prev'`).
|
||||
Future<int> cycleFrames(String direction) =>
|
||||
_bridge.call((t) => "window.api.cycleFrames($t, '$direction')");
|
||||
|
||||
// ─── Fire-and-await ────────────────────────────────────────────────
|
||||
|
||||
/// Scrolls the current iframe to [pageIndex] and awaits completion.
|
||||
Future<void> jumpToPage(int pageIndex) =>
|
||||
_bridge.callAndWait((t) => 'window.api.jumpToPage($t, $pageIndex)', 1000);
|
||||
|
||||
/// Restores the scroll position using a fractional [ratio] in [0,1].
|
||||
Future<void> restoreScrollPosition(double ratio) => _bridge.callAndWait(
|
||||
(t) => 'window.api.restoreScrollPosition($t, $ratio)',
|
||||
1000,
|
||||
);
|
||||
|
||||
/// Waits for the current frame to finish rendering.
|
||||
Future<void> waitForRender() =>
|
||||
_bridge.callAndWait((t) => 'window.api.waitForRender($t)', 1000);
|
||||
|
||||
/// Updates the reader theme/layout and awaits completion.
|
||||
///
|
||||
/// [theme] must be a JSON-serialisable map produced by `EpubTheme.toMap()`.
|
||||
Future<void> updateTheme(
|
||||
double viewWidth,
|
||||
double viewHeight,
|
||||
Map<String, dynamic> theme,
|
||||
) {
|
||||
final themeJson = jsonEncode(theme);
|
||||
return _bridge.callAndWait(
|
||||
(t) => 'window.api.updateTheme($t, $viewWidth, $viewHeight, $themeJson)',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fire-and-forget ───────────────────────────────────────────────
|
||||
|
||||
/// Checks whether there is an interactive element (image, etc.) at (x, y).
|
||||
Future<void> checkLongPressElementAt(double x, double y) =>
|
||||
_bridge.evaluate('window.api.checkLongPressElementAt($x, $y)');
|
||||
|
||||
/// Checks whether the tap at (x, y) hits a link, footnote, or other element.
|
||||
Future<void> checkTapElementAt(double x, double y) =>
|
||||
_bridge.evaluate('window.api.checkTapElementAt($x, $y)');
|
||||
}
|
||||
119
lib/services/epub/web/webview_bridge.dart
Normal file
119
lib/services/epub/web/webview_bridge.dart
Normal file
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
|
||||
/// Manages JS↔Dart communication over an [InAppWebViewController].
|
||||
///
|
||||
/// Provides token-based async call tracking so that callers can fire a JS
|
||||
/// method that will eventually invoke `FlutterBridge.onEventFinished(token)`,
|
||||
/// and await the result on the Dart side via [waitForEvent].
|
||||
///
|
||||
/// Typical usage:
|
||||
/// ```dart
|
||||
/// // Fire and forget the token; caller awaits separately.
|
||||
/// final token = await _bridge.call((t) => "window.api.loadFrame($t, ...)");
|
||||
/// await _bridge.waitForEvent(token);
|
||||
///
|
||||
/// // Fire and immediately await.
|
||||
/// await _bridge.callAndWait((t) => "window.api.jumpToPage($t, $idx)");
|
||||
/// ```
|
||||
class WebViewBridge {
|
||||
InAppWebViewController? _controller;
|
||||
|
||||
int _currentToken = 0;
|
||||
final Map<int, Completer<void>> _completers = {};
|
||||
|
||||
// ─── Controller lifecycle ──────────────────────────────────────────
|
||||
|
||||
/// Attaches a live [InAppWebViewController]. Call this in `onWebViewCreated`.
|
||||
void attach(InAppWebViewController controller) {
|
||||
_controller = controller;
|
||||
}
|
||||
|
||||
/// Detaches the controller and cancels all pending completers.
|
||||
void detach() {
|
||||
_controller = null;
|
||||
for (final completer in _completers.values) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(StateError('WebViewBridge detached'));
|
||||
}
|
||||
}
|
||||
_completers.clear();
|
||||
}
|
||||
|
||||
// ─── JS evaluation ─────────────────────────────────────────────────
|
||||
|
||||
/// Evaluates [source] in the WebView. No-ops if no controller is attached.
|
||||
Future<void> evaluate(String source) async {
|
||||
await _controller?.evaluateJavascript(source: source);
|
||||
}
|
||||
|
||||
// ─── Token management ──────────────────────────────────────────────
|
||||
|
||||
/// Allocates a new token and registers a [Completer] for it.
|
||||
///
|
||||
/// Embed the returned token in the JS call so JS can resolve it via
|
||||
/// `FlutterBridge.onEventFinished(token)`.
|
||||
int issueToken() {
|
||||
_currentToken++;
|
||||
_completers[_currentToken] = Completer<void>();
|
||||
return _currentToken;
|
||||
}
|
||||
|
||||
/// Called by the `onEventFinished` JS handler to resolve a pending token.
|
||||
///
|
||||
/// A [token] of `-1` is a sentinel for fire-and-forget notifications that
|
||||
/// do not need to be tracked.
|
||||
void resolveToken(int token) {
|
||||
if (token == -1) return;
|
||||
final completer = _completers.remove(token);
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Awaiting ──────────────────────────────────────────────────────
|
||||
|
||||
/// Waits for [token] to be resolved, or times out after [timeoutMs] ms.
|
||||
Future<void> waitForEvent(int token, [int timeoutMs = 10000]) async {
|
||||
final completer = _completers[token];
|
||||
if (completer == null) {
|
||||
debugPrint('WebViewBridge: no completer for token $token');
|
||||
return;
|
||||
}
|
||||
return completer.future.timeout(
|
||||
Duration(milliseconds: timeoutMs),
|
||||
onTimeout: () {
|
||||
_completers.remove(token);
|
||||
debugPrint('WebViewBridge: timeout for token $token');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Waits for all [tokens] to be resolved concurrently.
|
||||
Future<void> waitForEvents(List<int> tokens, [int timeoutMs = 10000]) async {
|
||||
await Future.wait(tokens.map((t) => waitForEvent(t, timeoutMs)));
|
||||
}
|
||||
|
||||
// ─── Convenience helpers ───────────────────────────────────────────
|
||||
|
||||
/// Issues a token, evaluates the JS returned by [source], and returns the
|
||||
/// token so the caller can [waitForEvent] later.
|
||||
Future<int> call(String Function(int token) source) async {
|
||||
final token = issueToken();
|
||||
await evaluate(source(token));
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Issues a token, evaluates the JS returned by [source], and immediately
|
||||
/// awaits [waitForEvent] before returning.
|
||||
Future<void> callAndWait(
|
||||
String Function(int token) source, [
|
||||
int timeoutMs = 10000,
|
||||
]) async {
|
||||
final token = issueToken();
|
||||
await evaluate(source(token));
|
||||
await waitForEvent(token, timeoutMs);
|
||||
}
|
||||
}
|
||||
174
lib/services/font_download_manager.dart
Normal file
174
lib/services/font_download_manager.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// 本地字体扫描与加载管理器
|
||||
///
|
||||
/// 扫描用户指定目录下的字体文件,通过 FontLoader 动态注册到 Flutter。
|
||||
class FontDownloadManager {
|
||||
static final FontDownloadManager _instance = FontDownloadManager._internal();
|
||||
factory FontDownloadManager() => _instance;
|
||||
FontDownloadManager._internal();
|
||||
|
||||
/// 已加载的字体 family 集合(避免重复注册)
|
||||
final Set<String> _loadedFonts = {};
|
||||
|
||||
/// 支持的字体文件扩展名
|
||||
static const List<String> _fontExtensions = ['.ttf', '.otf', '.ttc'];
|
||||
|
||||
/// 扫描指定目录下的字体文件
|
||||
Future<List<FontFileInfo>> scanFontDirectory(String dirPath) async {
|
||||
final dir = Directory(dirPath);
|
||||
if (!await dir.exists()) {
|
||||
debugPrint('[FontScan] 目录不存在: $dirPath');
|
||||
return [];
|
||||
}
|
||||
|
||||
final fonts = <FontFileInfo>[];
|
||||
try {
|
||||
await for (final entity in dir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final ext = path.extension(entity.path).toLowerCase();
|
||||
if (_fontExtensions.contains(ext)) {
|
||||
final fileName = path.basename(entity.path);
|
||||
fonts.add(FontFileInfo(
|
||||
path: entity.path,
|
||||
fileName: fileName,
|
||||
displayName: _formatFontName(fileName),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FontScan] 扫描异常: $e');
|
||||
}
|
||||
// 按文件名排序
|
||||
fonts.sort((a, b) => a.fileName.compareTo(b.fileName));
|
||||
debugPrint('[FontScan] 扫描完成: $dirPath, 找到 ${fonts.length} 个字体文件');
|
||||
return fonts;
|
||||
}
|
||||
|
||||
/// 从字体文件名生成显示名称
|
||||
String _formatFontName(String fileName) {
|
||||
// 移除扩展名
|
||||
var name = path.basenameWithoutExtension(fileName);
|
||||
// 替换常见分隔符为空格
|
||||
name = name.replaceAll('_', ' ').replaceAll('-', ' ');
|
||||
// 首字母大写
|
||||
return name.split(' ').map((w) {
|
||||
if (w.isEmpty) return w;
|
||||
return w[0].toUpperCase() + w.substring(1).toLowerCase();
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
/// 加载指定字体文件
|
||||
///
|
||||
/// [filePath] 字体文件完整路径
|
||||
/// [family] 可选的字体 family 名称(默认使用文件名)
|
||||
///
|
||||
/// 返回加载成功后的 family 名称
|
||||
Future<String?> loadFontFile(String filePath, {String? family}) async {
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) return null;
|
||||
|
||||
final fileName = path.basename(filePath);
|
||||
final familyName = family ?? path.basenameWithoutExtension(fileName);
|
||||
|
||||
// 已加载过,直接返回
|
||||
if (_loadedFonts.contains(familyName)) {
|
||||
return familyName;
|
||||
}
|
||||
|
||||
try {
|
||||
final bytes = await file.readAsBytes();
|
||||
final loader = FontLoader(familyName);
|
||||
loader.addFont(Future.value(ByteData.sublistView(bytes)));
|
||||
await loader.load();
|
||||
_loadedFonts.add(familyName);
|
||||
debugPrint('[FontDownload] 字体加载成功: $familyName');
|
||||
return familyName;
|
||||
} catch (e) {
|
||||
debugPrint('[FontDownload] 字体加载失败: $familyName, error=$e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 预加载已缓存的字体(应用启动时调用)
|
||||
Future<void> preloadCachedFont(String family) async {
|
||||
if (family.isEmpty) return;
|
||||
if (_loadedFonts.contains(family)) return;
|
||||
|
||||
// 尝试从默认字体目录加载
|
||||
try {
|
||||
final fontDir = await _getFontDir();
|
||||
final file = File(path.join(fontDir.path, '$family.ttf'));
|
||||
if (await file.exists()) {
|
||||
await loadFontFile(file.path, family: family);
|
||||
return;
|
||||
}
|
||||
// 尝试其他扩展名
|
||||
for (final ext in ['.otf', '.ttc']) {
|
||||
final file2 = File(path.join(fontDir.path, '$family$ext'));
|
||||
if (await file2.exists()) {
|
||||
await loadFontFile(file2.path, family: family);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FontDownload] 预加载失败: $family, error=$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取字体缓存目录
|
||||
Future<Directory> _getFontDir() async {
|
||||
if (Platform.isAndroid) {
|
||||
final fontDir = Directory('/sdcard/Documents/mooknote/fonts');
|
||||
if (!await fontDir.exists()) {
|
||||
await fontDir.create(recursive: true);
|
||||
}
|
||||
return fontDir;
|
||||
}
|
||||
// iOS / 桌面端 fallback
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final fontDir = Directory(path.join(appDir.path, 'fonts'));
|
||||
if (!await fontDir.exists()) {
|
||||
await fontDir.create(recursive: true);
|
||||
}
|
||||
return fontDir;
|
||||
}
|
||||
|
||||
/// 清理所有下载的字体缓存
|
||||
Future<void> clearAllCache() async {
|
||||
try {
|
||||
final fontDir = await _getFontDir();
|
||||
if (await fontDir.exists()) {
|
||||
await for (final entity in fontDir.list()) {
|
||||
if (entity is File) {
|
||||
try {
|
||||
await entity.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_loadedFonts.clear();
|
||||
debugPrint('[FontDownload] 字体缓存已清理');
|
||||
} catch (e) {
|
||||
debugPrint('[FontDownload] 清理缓存失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 字体文件信息
|
||||
class FontFileInfo {
|
||||
final String path;
|
||||
final String fileName;
|
||||
final String displayName;
|
||||
|
||||
FontFileInfo({
|
||||
required this.path,
|
||||
required this.fileName,
|
||||
required this.displayName,
|
||||
});
|
||||
}
|
||||
236
lib/services/sync/auto_backup_service.dart
Normal file
236
lib/services/sync/auto_backup_service.dart
Normal file
@@ -0,0 +1,236 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'backup_service.dart';
|
||||
|
||||
/// 自动备份服务 - 定时自动备份到下载目录
|
||||
class AutoBackupService {
|
||||
static final AutoBackupService instance = AutoBackupService._init();
|
||||
|
||||
AutoBackupService._init();
|
||||
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
|
||||
static const String _prefsKey = 'auto_backup_enabled';
|
||||
static const String _backupDirName = 'mooknote';
|
||||
static const int _maxBackups = 5;
|
||||
static const Duration _backupInterval = Duration(minutes: 5);
|
||||
|
||||
/// 是否正在运行
|
||||
bool get isRunning => _isRunning;
|
||||
|
||||
/// 获取自动备份状态
|
||||
Future<bool> getEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_prefsKey) ?? false;
|
||||
}
|
||||
|
||||
/// 设置自动备份状态
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefsKey, enabled);
|
||||
|
||||
if (enabled) {
|
||||
await start();
|
||||
} else {
|
||||
await stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动自动备份
|
||||
Future<void> start() async {
|
||||
if (_isRunning) return;
|
||||
|
||||
_isRunning = true;
|
||||
|
||||
// 立即执行一次备份
|
||||
await _performBackup();
|
||||
|
||||
// 启动定时器
|
||||
_timer = Timer.periodic(_backupInterval, (_) async {
|
||||
await _performBackup();
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止自动备份
|
||||
Future<void> stop() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_isRunning = false;
|
||||
}
|
||||
|
||||
/// 执行备份
|
||||
Future<void> _performBackup() async {
|
||||
try {
|
||||
final backupDir = await _getBackupDirectory();
|
||||
if (backupDir == null) {
|
||||
debugPrint('AutoBackup: 无法获取备份目录');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保备份目录存在
|
||||
if (!await backupDir.exists()) {
|
||||
await backupDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
final result = await BackupService.instance.exportDataForAutoBackup();
|
||||
|
||||
if (!result.success) {
|
||||
debugPrint('AutoBackup: 导出失败 - ${result.errorMessage}');
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成备份文件名
|
||||
final fileName = 'auto_backup_${_formatDateTime(DateTime.now())}.zip';
|
||||
final backupFile = File(path.join(backupDir.path, fileName));
|
||||
|
||||
// 写入备份文件
|
||||
await backupFile.writeAsBytes(result.zipBytes!);
|
||||
|
||||
debugPrint('AutoBackup: 备份成功 - ${backupFile.path}');
|
||||
|
||||
// 清理旧备份,只保留最新的5个
|
||||
await _cleanupOldBackups(backupDir);
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('AutoBackup: 备份失败 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取备份目录(下载目录/mooknote)
|
||||
Future<Directory?> _getBackupDirectory() async {
|
||||
try {
|
||||
if (Platform.isAndroid) {
|
||||
// 优先级 1: 官方 API 获取下载目录
|
||||
try {
|
||||
final dirs = await getExternalStorageDirectories(
|
||||
type: StorageDirectory.downloads,
|
||||
);
|
||||
if (dirs != null && dirs.isNotEmpty) {
|
||||
return Directory('${dirs.first.path}/$_backupDirName');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 优先级 2: 标准路径直接拼
|
||||
final standardPath = '/storage/emulated/0/Download/$_backupDirName';
|
||||
final standardDir = Directory(standardPath);
|
||||
if (await standardDir.parent.exists()) {
|
||||
return standardDir;
|
||||
}
|
||||
|
||||
// 优先级 3: 从外部存储路径推导(旧逻辑兜底)
|
||||
final externalDir = await getExternalStorageDirectory();
|
||||
if (externalDir != null) {
|
||||
final segments = externalDir.uri.pathSegments;
|
||||
if (segments.length >= 3) {
|
||||
final pkg = segments[segments.length - 3];
|
||||
final downloadPath = externalDir.path.replaceAll(
|
||||
'/Android/data/$pkg/files',
|
||||
'/Download',
|
||||
);
|
||||
return Directory('$downloadPath/$_backupDirName');
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级 4: 降级到 app 内部目录
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
return Directory('${appDir.path}/$_backupDirName');
|
||||
|
||||
} else if (Platform.isIOS) {
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
return Directory('${docDir.path}/$_backupDirName');
|
||||
} else {
|
||||
// 桌面端
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home != null) {
|
||||
return Directory('$home/Downloads/$_backupDirName');
|
||||
}
|
||||
final docDir = await getApplicationDocumentsDirectory();
|
||||
return Directory('${docDir.path}/$_backupDirName');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('AutoBackup: 获取备份目录失败 - $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理旧备份,只保留最新的5个
|
||||
Future<void> _cleanupOldBackups(Directory backupDir) async {
|
||||
try {
|
||||
final files = await backupDir
|
||||
.list()
|
||||
.where((entity) => entity is File && entity.path.endsWith('.zip'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
files.sort((a, b) {
|
||||
final aStat = a.statSync();
|
||||
final bStat = b.statSync();
|
||||
return bStat.modified.compareTo(aStat.modified);
|
||||
});
|
||||
|
||||
// 删除超过10个的旧备份
|
||||
if (files.length > _maxBackups) {
|
||||
for (var i = _maxBackups; i < files.length; i++) {
|
||||
try {
|
||||
await files[i].delete();
|
||||
debugPrint('AutoBackup: 删除旧备份 - ${files[i].path}');
|
||||
} catch (e) {
|
||||
debugPrint('AutoBackup: 删除旧备份失败 - $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('AutoBackup: 清理旧备份失败 - $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取备份文件列表
|
||||
Future<List<File>> getBackupFiles() async {
|
||||
try {
|
||||
final backupDir = await _getBackupDirectory();
|
||||
if (backupDir == null || !await backupDir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final files = await backupDir
|
||||
.list()
|
||||
.where((entity) => entity is File && entity.path.endsWith('.zip'))
|
||||
.cast<File>()
|
||||
.toList();
|
||||
|
||||
// 按修改时间排序(最新的在前)
|
||||
files.sort((a, b) {
|
||||
final aStat = a.statSync();
|
||||
final bStat = b.statSync();
|
||||
return bStat.modified.compareTo(aStat.modified);
|
||||
});
|
||||
|
||||
return files;
|
||||
} catch (e) {
|
||||
debugPrint('AutoBackup: 获取备份列表失败 - $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取备份目录路径
|
||||
Future<String?> getBackupDirectoryPath() async {
|
||||
final dir = await _getBackupDirectory();
|
||||
return dir?.path;
|
||||
}
|
||||
|
||||
/// 格式化日期时间用于文件名
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
|
||||
}
|
||||
|
||||
String _pad(int number) {
|
||||
return number.toString().padLeft(2, '0');
|
||||
}
|
||||
}
|
||||
855
lib/services/sync/backup_service.dart
Normal file
855
lib/services/sync/backup_service.dart
Normal file
@@ -0,0 +1,855 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:archive/archive_io.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import '../database_helper.dart';
|
||||
import '../user_prefs.dart';
|
||||
|
||||
/// 数据备份服务 - 支持导出和导入数据(包含图片)
|
||||
class BackupService {
|
||||
static final BackupService instance = BackupService._init();
|
||||
|
||||
BackupService._init();
|
||||
|
||||
// ─── 共享导出逻辑 ─────────────────────────────────────
|
||||
|
||||
/// 收集所有表数据和图片,构建 ZIP 字节
|
||||
Future<_ExportData> _buildExportData() async {
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
|
||||
final movies = await db.query('movies');
|
||||
final books = await db.query('books');
|
||||
final notes = await db.query('notes');
|
||||
final movieReviews = await db.query('movie_reviews');
|
||||
final moviePosters = await db.query('movie_posters');
|
||||
final bookReviews = await db.query('book_reviews');
|
||||
final bookExcerpts = await db.query('book_excerpts');
|
||||
final tags = await db.query('tags');
|
||||
final readerBooks = await db.query('reader_books');
|
||||
final bookAnnotations = await db.query('book_annotations');
|
||||
|
||||
// 收集图片路径
|
||||
final imagePaths = <String>{};
|
||||
for (final m in movies) {
|
||||
final p = m['poster_path'] as String?;
|
||||
if (p != null && p.isNotEmpty) imagePaths.add(p);
|
||||
}
|
||||
for (final b in books) {
|
||||
final p = b['cover_path'] as String?;
|
||||
if (p != null && p.isNotEmpty) imagePaths.add(p);
|
||||
}
|
||||
for (final p in moviePosters) {
|
||||
final pp = p['poster_path'] as String?;
|
||||
if (pp != null && pp.isNotEmpty) imagePaths.add(pp);
|
||||
}
|
||||
for (final n in notes) {
|
||||
final imagesJson = n['images'] as String?;
|
||||
if (imagesJson != null && imagesJson.isNotEmpty) {
|
||||
try {
|
||||
for (final ip in jsonDecode(imagesJson) as List<dynamic>) {
|
||||
if (ip is String && ip.isNotEmpty) imagePaths.add(ip);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[BackupService] 笔记图片解析失败 (noteId=${n['id']}): $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
// reader_books 的封面在 epub_books/ 目录下,由 epub_books 归档处理
|
||||
// 不加入 imagePaths,避免 basename 碰撞导致所有封面变成同一个路径
|
||||
|
||||
final userPrefs = UserPrefs();
|
||||
final userInfo = {
|
||||
'nickname': userPrefs.nickname,
|
||||
'motto': userPrefs.motto,
|
||||
'avatarPath': userPrefs.avatarPath,
|
||||
};
|
||||
final avatarPath = userPrefs.avatarPath;
|
||||
if (avatarPath != null && avatarPath.isNotEmpty) imagePaths.add(avatarPath);
|
||||
|
||||
// 构建备份数据
|
||||
final backupData = {
|
||||
'version': 2,
|
||||
'exportTime': DateTime.now().toIso8601String(),
|
||||
'appName': 'MookNote',
|
||||
'hasImages': true,
|
||||
'userInfo': userInfo,
|
||||
'sharedPrefs': await _exportSharedPrefs(),
|
||||
'data': {
|
||||
'movies': movies,
|
||||
'books': books,
|
||||
'notes': notes,
|
||||
'movie_reviews': movieReviews,
|
||||
'movie_posters': moviePosters,
|
||||
'book_reviews': bookReviews,
|
||||
'book_excerpts': bookExcerpts,
|
||||
'tags': tags,
|
||||
'reader_books': readerBooks,
|
||||
'book_annotations': bookAnnotations,
|
||||
},
|
||||
};
|
||||
|
||||
// 创建 ZIP(逐文件写入磁盘,避免全部加载到内存)
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZipPath = path.join(tempDir.path, 'mooknote_backup_temp.zip');
|
||||
final encoder = ZipFileEncoder();
|
||||
encoder.create(tempZipPath);
|
||||
|
||||
try {
|
||||
// data.json
|
||||
final jsonString = const JsonEncoder.withIndent(' ').convert(backupData);
|
||||
final jsonBytes = Uint8List.fromList(utf8.encode(jsonString));
|
||||
final dataFile = File(path.join(tempDir.path, 'mooknote_data.json'));
|
||||
await dataFile.writeAsBytes(jsonBytes);
|
||||
encoder.addFile(dataFile, 'data.json');
|
||||
await dataFile.delete();
|
||||
|
||||
int imageCount = 0;
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesRoot = path.join(appDir.path, 'images');
|
||||
|
||||
for (final imagePath in imagePaths) {
|
||||
final file = File(imagePath);
|
||||
if (await file.exists()) {
|
||||
String relativePath;
|
||||
if (imagePath.startsWith(imagesRoot)) {
|
||||
relativePath = imagePath.substring(imagesRoot.length + 1);
|
||||
} else {
|
||||
relativePath = path.basename(imagePath);
|
||||
}
|
||||
encoder.addFile(file, 'images/$relativePath');
|
||||
imageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 收集 epub_books 目录下的 epub 文件
|
||||
int epubCount = 0;
|
||||
final epubRoot = path.join(appDir.path, 'epub_books');
|
||||
final epubDir = Directory(epubRoot);
|
||||
if (await epubDir.exists()) {
|
||||
await for (final entity in epubDir.list(recursive: true)) {
|
||||
if (entity is File) {
|
||||
final relativePath = entity.path.substring(epubRoot.length + 1);
|
||||
encoder.addFile(entity, 'epub_books/$relativePath');
|
||||
epubCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encoder.close();
|
||||
|
||||
// 读取最终 zip 文件
|
||||
final zipFile = File(tempZipPath);
|
||||
final zipBytes = await zipFile.readAsBytes();
|
||||
await zipFile.delete();
|
||||
|
||||
return _ExportData(
|
||||
zipBytes: Uint8List.fromList(zipBytes),
|
||||
movieCount: movies.length,
|
||||
bookCount: books.length,
|
||||
noteCount: notes.length,
|
||||
imageCount: imageCount,
|
||||
epubCount: epubCount,
|
||||
);
|
||||
} catch (e) {
|
||||
encoder.close();
|
||||
try { await File(tempZipPath).delete(); } catch (_) {}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 手动导出 ─────────────────────────────────────────
|
||||
|
||||
/// 导出所有数据和图片为 ZIP 文件,并选择保存路径
|
||||
Future<ExportResult> exportDataWithImages() async {
|
||||
try {
|
||||
final data = await _buildExportData();
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName = 'mooknote_backup_${_formatDateTime(DateTime.now())}.zip';
|
||||
final tempFilePath = path.join(tempDir.path, fileName);
|
||||
await File(tempFilePath).writeAsBytes(data.zipBytes);
|
||||
|
||||
String? finalPath;
|
||||
try {
|
||||
final outputPath = await FilePicker.platform.saveFile(
|
||||
dialogTitle: '保存备份文件',
|
||||
fileName: fileName,
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip'],
|
||||
bytes: data.zipBytes,
|
||||
);
|
||||
if (outputPath == null) {
|
||||
return ExportResult.cancelled();
|
||||
}
|
||||
finalPath = outputPath;
|
||||
if (finalPath != tempFilePath) {
|
||||
await File(finalPath).writeAsBytes(data.zipBytes);
|
||||
}
|
||||
} catch (e) {
|
||||
finalPath = tempFilePath;
|
||||
}
|
||||
|
||||
return ExportResult.success(
|
||||
filePath: finalPath,
|
||||
movieCount: data.movieCount,
|
||||
bookCount: data.bookCount,
|
||||
noteCount: data.noteCount,
|
||||
imageCount: data.imageCount,
|
||||
);
|
||||
} catch (e) {
|
||||
return ExportResult.error('导出失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 分享备份文件
|
||||
Future<void> shareBackup(String filePath) async {
|
||||
final file = XFile(filePath);
|
||||
await Share.shareXFiles([file], subject: 'MookNote 数据备份', text: '这是我的 MookNote 数据备份文件');
|
||||
}
|
||||
|
||||
// ─── 自动备份导出 ─────────────────────────────────────
|
||||
|
||||
/// 导出数据用于自动备份(返回字节数据)
|
||||
Future<AutoBackupExportResult> exportDataForAutoBackup() async {
|
||||
try {
|
||||
final data = await _buildExportData();
|
||||
return AutoBackupExportResult.success(
|
||||
zipBytes: data.zipBytes,
|
||||
movieCount: data.movieCount,
|
||||
bookCount: data.bookCount,
|
||||
noteCount: data.noteCount,
|
||||
imageCount: data.imageCount,
|
||||
epubCount: data.epubCount,
|
||||
);
|
||||
} catch (e) {
|
||||
return AutoBackupExportResult.error('导出失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 导入 ─────────────────────────────────────────────
|
||||
|
||||
/// 选择并导入备份文件(支持 ZIP 和旧版 JSON)
|
||||
Future<ImportResult> importData() async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['zip', 'json'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) return ImportResult.cancelled();
|
||||
|
||||
final filePath = result.files.first.path;
|
||||
if (filePath == null) return ImportResult.error('无法读取文件路径');
|
||||
|
||||
final file = File(filePath);
|
||||
final extension = path.extension(filePath).toLowerCase();
|
||||
|
||||
Map<String, dynamic> backupData;
|
||||
int imageCount = 0;
|
||||
// 完整相对路径 → 新绝对路径 的映射(避免同名文件碰撞)
|
||||
final imagePathMap = <String, String>{};
|
||||
// epub_books/ 内相对路径 → 新绝对路径 的映射
|
||||
final epubFileMap = <String, String>{};
|
||||
|
||||
if (extension == '.zip') {
|
||||
final bytes = await file.readAsBytes();
|
||||
final archive = ZipDecoder().decodeBytes(bytes);
|
||||
|
||||
final dataFile = archive.findFile('data.json');
|
||||
if (dataFile == null) return ImportResult.error('备份文件中没有找到数据文件');
|
||||
|
||||
backupData = jsonDecode(utf8.decode(dataFile.content as List<int>)) as Map<String, dynamic>;
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'images'));
|
||||
if (!await imagesDir.exists()) await imagesDir.create(recursive: true);
|
||||
|
||||
for (final archiveFile in archive) {
|
||||
if (archiveFile.name.startsWith('images/')) {
|
||||
final relativePath = archiveFile.name.substring(7);
|
||||
final outputFile = File(path.join(imagesDir.path, relativePath));
|
||||
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
// 用完整相对路径做 key,避免不同目录下同名文件碰撞
|
||||
imagePathMap[relativePath] = outputFile.path;
|
||||
imageCount++;
|
||||
} else if (archiveFile.name.startsWith('epub_books/')) {
|
||||
final relativePath = archiveFile.name.substring(12);
|
||||
final epubDir = Directory(path.join(appDir.path, 'epub_books'));
|
||||
if (!await epubDir.exists()) await epubDir.create(recursive: true);
|
||||
final outputFile = File(path.join(epubDir.path, relativePath));
|
||||
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
epubFileMap[relativePath] = outputFile.path;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 旧版 JSON
|
||||
backupData = jsonDecode(await file.readAsString()) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
if (!backupData.containsKey('data')) return ImportResult.error('无效的备份文件格式');
|
||||
|
||||
// 验证版本
|
||||
final version = backupData['version'] as int? ?? 1;
|
||||
if (version > 2) {
|
||||
debugPrint('[BackupService] 警告: 备份版本 $version 高于当前支持的版本 2,部分数据可能丢失');
|
||||
}
|
||||
|
||||
final data = backupData['data'] as Map<String, dynamic>;
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
|
||||
final moviesCols = await _getTableColumns(db, 'movies');
|
||||
final booksCols = await _getTableColumns(db, 'books');
|
||||
final notesCols = await _getTableColumns(db, 'notes');
|
||||
final movieReviewsCols = await _getTableColumns(db, 'movie_reviews');
|
||||
final moviePostersCols = await _getTableColumns(db, 'movie_posters');
|
||||
final bookReviewsCols = await _getTableColumns(db, 'book_reviews');
|
||||
final bookExcerptsCols = await _getTableColumns(db, 'book_excerpts');
|
||||
final tagsCols = await _getTableColumns(db, 'tags');
|
||||
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
||||
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
||||
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('movie_reviews');
|
||||
await txn.delete('movie_posters');
|
||||
await txn.delete('book_reviews');
|
||||
await txn.delete('book_excerpts');
|
||||
await txn.delete('book_annotations');
|
||||
await txn.delete('movies');
|
||||
await txn.delete('books');
|
||||
await txn.delete('notes');
|
||||
await txn.delete('reader_books');
|
||||
await txn.delete('tags');
|
||||
|
||||
if (data.containsKey('movies')) {
|
||||
for (final m in data['movies'] as List) {
|
||||
await txn.insert('movies', _updateImagePath(_convertToDbMapSafe(m, moviesCols), 'poster_path', imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('books')) {
|
||||
for (final b in data['books'] as List) {
|
||||
await txn.insert('books', _updateImagePath(_convertToDbMapSafe(b, booksCols), 'cover_path', imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('notes')) {
|
||||
for (final n in data['notes'] as List) {
|
||||
await txn.insert('notes', _updateNoteImagesPath(_convertToDbMapSafe(n, notesCols), imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('movie_reviews')) {
|
||||
for (final r in data['movie_reviews'] as List) {
|
||||
await txn.insert('movie_reviews', _convertToDbMapSafe(r, movieReviewsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('movie_posters')) {
|
||||
for (final p in data['movie_posters'] as List) {
|
||||
await txn.insert('movie_posters', _updateImagePath(_convertToDbMapSafe(p, moviePostersCols), 'poster_path', imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('book_reviews')) {
|
||||
for (final r in data['book_reviews'] as List) {
|
||||
await txn.insert('book_reviews', _convertToDbMapSafe(r, bookReviewsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('book_excerpts')) {
|
||||
for (final e in data['book_excerpts'] as List) {
|
||||
await txn.insert('book_excerpts', _convertToDbMapSafe(e, bookExcerptsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('reader_books')) {
|
||||
for (final rb in data['reader_books'] as List) {
|
||||
var row = _updateImagePath(_convertToDbMapSafe(rb, readerBooksCols), 'cover_path', imagePathMap);
|
||||
row = _updateEpubPaths(row, epubFileMap);
|
||||
await txn.insert('reader_books', row);
|
||||
}
|
||||
}
|
||||
if (data.containsKey('book_annotations')) {
|
||||
for (final a in data['book_annotations'] as List) {
|
||||
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('tags')) {
|
||||
for (final t in data['tags'] as List) {
|
||||
final map = _convertToDbMapSafe(t, tagsCols);
|
||||
await txn.rawInsert(
|
||||
'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)',
|
||||
[map['id'], map['name'], map['type'], map['created_at']],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复用户信息
|
||||
await _restoreUserInfo(backupData, imagePathMap);
|
||||
|
||||
return ImportResult.success(_buildStats(data, imageCount));
|
||||
} catch (e) {
|
||||
return ImportResult.error('导入失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 ZIP 字节数据恢复(供 WebDAV 同步等场景使用)
|
||||
Future<ImportResult> restoreFromZipBytes(Uint8List zipBytes) async {
|
||||
try {
|
||||
final archive = ZipDecoder().decodeBytes(zipBytes);
|
||||
final dataFile = archive.findFile('data.json');
|
||||
if (dataFile == null) return ImportResult.error('备份文件中没有找到数据文件');
|
||||
|
||||
final backupData = jsonDecode(utf8.decode(dataFile.content as List<int>)) as Map<String, dynamic>;
|
||||
final imagePathMap = <String, String>{};
|
||||
final epubFileMap = <String, String>{};
|
||||
int imageCount = 0;
|
||||
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final imagesDir = Directory(path.join(appDir.path, 'images'));
|
||||
if (!await imagesDir.exists()) await imagesDir.create(recursive: true);
|
||||
|
||||
for (final archiveFile in archive) {
|
||||
if (archiveFile.name.startsWith('images/')) {
|
||||
final relativePath = archiveFile.name.substring(7);
|
||||
final outputFile = File(path.join(imagesDir.path, relativePath));
|
||||
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
imagePathMap[relativePath] = outputFile.path;
|
||||
imageCount++;
|
||||
} else if (archiveFile.name.startsWith('epub_books/')) {
|
||||
final relativePath = archiveFile.name.substring(12);
|
||||
final epubDir = Directory(path.join(appDir.path, 'epub_books'));
|
||||
if (!await epubDir.exists()) await epubDir.create(recursive: true);
|
||||
final outputFile = File(path.join(epubDir.path, relativePath));
|
||||
if (!await outputFile.parent.exists()) await outputFile.parent.create(recursive: true);
|
||||
await outputFile.writeAsBytes(archiveFile.content as List<int>);
|
||||
epubFileMap[relativePath] = outputFile.path;
|
||||
}
|
||||
}
|
||||
|
||||
if (!backupData.containsKey('data')) return ImportResult.error('无效的备份文件格式');
|
||||
|
||||
final version = backupData['version'] as int? ?? 1;
|
||||
if (version > 2) {
|
||||
debugPrint('[BackupService] 警告: 备份版本 $version 高于当前支持的版本 2,部分数据可能丢失');
|
||||
}
|
||||
|
||||
final data = backupData['data'] as Map<String, dynamic>;
|
||||
final db = await DatabaseHelper.instance.database;
|
||||
|
||||
final moviesCols = await _getTableColumns(db, 'movies');
|
||||
final booksCols = await _getTableColumns(db, 'books');
|
||||
final notesCols = await _getTableColumns(db, 'notes');
|
||||
final movieReviewsCols = await _getTableColumns(db, 'movie_reviews');
|
||||
final moviePostersCols = await _getTableColumns(db, 'movie_posters');
|
||||
final bookReviewsCols = await _getTableColumns(db, 'book_reviews');
|
||||
final bookExcerptsCols = await _getTableColumns(db, 'book_excerpts');
|
||||
final tagsCols = await _getTableColumns(db, 'tags');
|
||||
final readerBooksCols = await _getTableColumns(db, 'reader_books');
|
||||
final bookAnnotationsCols = await _getTableColumns(db, 'book_annotations');
|
||||
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('movie_reviews');
|
||||
await txn.delete('movie_posters');
|
||||
await txn.delete('book_reviews');
|
||||
await txn.delete('book_excerpts');
|
||||
await txn.delete('book_annotations');
|
||||
await txn.delete('movies');
|
||||
await txn.delete('books');
|
||||
await txn.delete('notes');
|
||||
await txn.delete('reader_books');
|
||||
await txn.delete('tags'); // 修复: 之前漏删 tags 表
|
||||
|
||||
if (data.containsKey('movies')) {
|
||||
for (final m in data['movies'] as List) {
|
||||
await txn.insert('movies', _updateImagePath(_convertToDbMapSafe(m, moviesCols), 'poster_path', imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('books')) {
|
||||
for (final b in data['books'] as List) {
|
||||
await txn.insert('books', _updateImagePath(_convertToDbMapSafe(b, booksCols), 'cover_path', imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('notes')) {
|
||||
for (final n in data['notes'] as List) {
|
||||
await txn.insert('notes', _updateNoteImagesPath(_convertToDbMapSafe(n, notesCols), imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('movie_reviews')) {
|
||||
for (final r in data['movie_reviews'] as List) {
|
||||
await txn.insert('movie_reviews', _convertToDbMapSafe(r, movieReviewsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('movie_posters')) {
|
||||
for (final p in data['movie_posters'] as List) {
|
||||
await txn.insert('movie_posters', _updateImagePath(_convertToDbMapSafe(p, moviePostersCols), 'poster_path', imagePathMap));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('book_reviews')) {
|
||||
for (final r in data['book_reviews'] as List) {
|
||||
await txn.insert('book_reviews', _convertToDbMapSafe(r, bookReviewsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('book_excerpts')) {
|
||||
for (final e in data['book_excerpts'] as List) {
|
||||
await txn.insert('book_excerpts', _convertToDbMapSafe(e, bookExcerptsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('reader_books')) {
|
||||
for (final rb in data['reader_books'] as List) {
|
||||
var row = _updateImagePath(_convertToDbMapSafe(rb, readerBooksCols), 'cover_path', imagePathMap);
|
||||
row = _updateEpubPaths(row, epubFileMap);
|
||||
await txn.insert('reader_books', row);
|
||||
}
|
||||
}
|
||||
if (data.containsKey('book_annotations')) {
|
||||
for (final a in data['book_annotations'] as List) {
|
||||
await txn.insert('book_annotations', _convertToDbMapSafe(a, bookAnnotationsCols));
|
||||
}
|
||||
}
|
||||
if (data.containsKey('tags')) {
|
||||
for (final t in data['tags'] as List) {
|
||||
final map = _convertToDbMapSafe(t, tagsCols);
|
||||
await txn.rawInsert(
|
||||
'INSERT OR IGNORE INTO tags (id, name, type, created_at) VALUES (?, ?, ?, ?)',
|
||||
[map['id'], map['name'], map['type'], map['created_at']],
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await _restoreUserInfo(backupData, imagePathMap);
|
||||
return ImportResult.success(_buildStats(data, imageCount));
|
||||
} catch (e) {
|
||||
return ImportResult.error('恢复失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 内部辅助方法 ─────────────────────────────────────
|
||||
|
||||
Future<void> _restoreUserInfo(Map<String, dynamic> backupData, Map<String, String> imagePathMap) async {
|
||||
if (!backupData.containsKey('userInfo')) return;
|
||||
final userInfo = backupData['userInfo'] as Map<String, dynamic>;
|
||||
final userPrefs = UserPrefs();
|
||||
|
||||
if (userInfo.containsKey('nickname')) await userPrefs.setNickname(userInfo['nickname'] as String);
|
||||
if (userInfo.containsKey('motto')) await userPrefs.setMotto(userInfo['motto'] as String);
|
||||
if (userInfo.containsKey('avatarPath')) {
|
||||
final avatarPath = userInfo['avatarPath'] as String?;
|
||||
if (avatarPath != null && avatarPath.isNotEmpty) {
|
||||
final relPath = _toRelativePath(avatarPath);
|
||||
if (imagePathMap.containsKey(relPath)) {
|
||||
await userPrefs.setAvatarPath(imagePathMap[relPath]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复完整 SharedPreferences
|
||||
if (backupData.containsKey('sharedPrefs')) {
|
||||
await _restoreSharedPrefs(backupData['sharedPrefs'] as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出完整 SharedPreferences
|
||||
Future<Map<String, dynamic>> _exportSharedPrefs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keys = prefs.getKeys();
|
||||
final map = <String, dynamic>{};
|
||||
for (final key in keys) {
|
||||
map[key] = prefs.get(key);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// 恢复 SharedPreferences(保留当前设备的同步和路径配置)
|
||||
Future<void> _restoreSharedPrefs(Map<String, dynamic> data) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// 这些键是设备特定的,不应从备份恢复
|
||||
const skipKeys = {
|
||||
'avatarPath',
|
||||
'webdav_config',
|
||||
'webdav_last_sync',
|
||||
'webdav_auto_sync',
|
||||
'webdav_auto_sync_interval',
|
||||
};
|
||||
for (final entry in data.entries) {
|
||||
final key = entry.key;
|
||||
final value = entry.value;
|
||||
if (skipKeys.contains(key)) continue;
|
||||
if (value is String) {
|
||||
await prefs.setString(key, value);
|
||||
} else if (value is int) {
|
||||
await prefs.setInt(key, value);
|
||||
} else if (value is double) {
|
||||
await prefs.setDouble(key, value);
|
||||
} else if (value is bool) {
|
||||
await prefs.setBool(key, value);
|
||||
} else if (value is List) {
|
||||
await prefs.setStringList(key, value.cast<String>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, int> _buildStats(Map<String, dynamic> data, int imageCount) {
|
||||
final stats = <String, int>{};
|
||||
if (data.containsKey('movies')) stats['影视'] = (data['movies'] as List).length;
|
||||
if (data.containsKey('books')) stats['书籍'] = (data['books'] as List).length;
|
||||
if (data.containsKey('notes')) stats['笔记'] = (data['notes'] as List).length;
|
||||
if (data.containsKey('movie_reviews')) stats['影评'] = (data['movie_reviews'] as List).length;
|
||||
if (data.containsKey('movie_posters')) stats['海报'] = (data['movie_posters'] as List).length;
|
||||
if (data.containsKey('book_reviews')) stats['书评'] = (data['book_reviews'] as List).length;
|
||||
if (data.containsKey('book_excerpts')) stats['书摘'] = (data['book_excerpts'] as List).length;
|
||||
if (data.containsKey('tags')) stats['标签'] = (data['tags'] as List).length;
|
||||
if (data.containsKey('reader_books')) stats['阅读'] = (data['reader_books'] as List).length;
|
||||
if (data.containsKey('book_annotations')) stats['批注'] = (data['book_annotations'] as List).length;
|
||||
if (imageCount > 0) stats['图片'] = imageCount;
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// 将绝对路径转为 images/ 下的相对路径(用于 imagePathMap key)
|
||||
String _toRelativePath(String absolutePath) {
|
||||
// 尝试提取 images/ 后面的部分
|
||||
final idx = absolutePath.indexOf('/images/');
|
||||
if (idx >= 0) return absolutePath.substring(idx + 8); // skip '/images/'
|
||||
// Windows 路径
|
||||
final winIdx = absolutePath.indexOf('\\images\\');
|
||||
if (winIdx >= 0) return absolutePath.substring(winIdx + 8);
|
||||
return path.basename(absolutePath);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _convertToDbMapSafe(dynamic item, Set<String> validColumns) {
|
||||
final raw = _convertToDbMap(item);
|
||||
if (raw.isEmpty) return raw;
|
||||
return Map.fromEntries(raw.entries.where((e) => validColumns.contains(e.key)));
|
||||
}
|
||||
|
||||
Future<Set<String>> _getTableColumns(Database db, String table) async {
|
||||
final columns = await db.rawQuery('PRAGMA table_info($table)');
|
||||
return columns.map((c) => c['name'] as String).toSet();
|
||||
}
|
||||
|
||||
Map<String, dynamic> _convertToDbMap(dynamic item) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
return item.map((key, value) {
|
||||
if (value is bool) return MapEntry(key, value ? 1 : 0);
|
||||
return MapEntry(key, value);
|
||||
});
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// 更新 epub 阅读器的 file_path 和 cover_path
|
||||
Map<String, dynamic> _updateEpubPaths(Map<String, dynamic> item, Map<String, String> epubFileMap) {
|
||||
if (epubFileMap.isEmpty) return item;
|
||||
final newItem = Map<String, dynamic>.from(item);
|
||||
|
||||
final oldFilePath = item['file_path'] as String?;
|
||||
if (oldFilePath != null && oldFilePath.isNotEmpty) {
|
||||
final oldRel = _toEpubRelativePath(oldFilePath);
|
||||
if (oldRel != null && epubFileMap.containsKey(oldRel)) {
|
||||
newItem['file_path'] = epubFileMap[oldRel];
|
||||
}
|
||||
}
|
||||
|
||||
final oldCoverPath = item['cover_path'] as String?;
|
||||
if (oldCoverPath != null && oldCoverPath.isNotEmpty) {
|
||||
final oldRel = _toEpubRelativePath(oldCoverPath);
|
||||
if (oldRel != null && epubFileMap.containsKey(oldRel)) {
|
||||
newItem['cover_path'] = epubFileMap[oldRel];
|
||||
}
|
||||
}
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// 从绝对路径中提取 epub_books/ 下的相对路径
|
||||
String? _toEpubRelativePath(String absolutePath) {
|
||||
final idx = absolutePath.indexOf('/epub_books/');
|
||||
if (idx >= 0) return absolutePath.substring(idx + 13); // skip '/epub_books/'
|
||||
final winIdx = absolutePath.indexOf('\\epub_books\\');
|
||||
if (winIdx >= 0) return absolutePath.substring(winIdx + 13);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 更新单值图片路径(poster_path / cover_path)
|
||||
Map<String, dynamic> _updateImagePath(Map<String, dynamic> item, String pathField, Map<String, String> imagePathMap) {
|
||||
final newItem = Map<String, dynamic>.from(item);
|
||||
final oldPath = item[pathField] as String?;
|
||||
if (oldPath != null && oldPath.isNotEmpty) {
|
||||
final relPath = _toRelativePath(oldPath);
|
||||
if (imagePathMap.containsKey(relPath)) {
|
||||
newItem[pathField] = imagePathMap[relPath];
|
||||
}
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// 更新笔记多图路径(images JSON 列表)
|
||||
Map<String, dynamic> _updateNoteImagesPath(Map<String, dynamic> item, Map<String, String> imagePathMap) {
|
||||
final newItem = Map<String, dynamic>.from(item);
|
||||
final imagesJson = item['images'] as String?;
|
||||
if (imagesJson == null || imagesJson.isEmpty) return newItem;
|
||||
|
||||
try {
|
||||
final images = jsonDecode(imagesJson) as List<dynamic>;
|
||||
final updatedImages = <String>[];
|
||||
for (final imagePath in images) {
|
||||
if (imagePath is String && imagePath.isNotEmpty) {
|
||||
final relPath = _toRelativePath(imagePath);
|
||||
updatedImages.add(imagePathMap[relPath] ?? imagePath);
|
||||
}
|
||||
}
|
||||
newItem['images'] = jsonEncode(updatedImages);
|
||||
} catch (e) {
|
||||
debugPrint('[BackupService] 笔记图片路径更新失败: $e');
|
||||
}
|
||||
return newItem;
|
||||
}
|
||||
|
||||
String _formatDateTime(DateTime dateTime) {
|
||||
return '${dateTime.year}${_pad(dateTime.month)}${_pad(dateTime.day)}_${_pad(dateTime.hour)}${_pad(dateTime.minute)}${_pad(dateTime.second)}';
|
||||
}
|
||||
|
||||
String _pad(int number) => number.toString().padLeft(2, '0');
|
||||
}
|
||||
|
||||
/// 导出中间数据
|
||||
class _ExportData {
|
||||
final Uint8List zipBytes;
|
||||
final int movieCount;
|
||||
final int bookCount;
|
||||
final int noteCount;
|
||||
final int imageCount;
|
||||
final int epubCount;
|
||||
|
||||
_ExportData({
|
||||
required this.zipBytes,
|
||||
required this.movieCount,
|
||||
required this.bookCount,
|
||||
required this.noteCount,
|
||||
required this.imageCount,
|
||||
this.epubCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 结果类型 ──────────────────────────────────────────
|
||||
|
||||
class AutoBackupExportResult {
|
||||
final bool success;
|
||||
final String? errorMessage;
|
||||
final Uint8List? zipBytes;
|
||||
final int movieCount;
|
||||
final int bookCount;
|
||||
final int noteCount;
|
||||
final int imageCount;
|
||||
final int epubCount;
|
||||
|
||||
AutoBackupExportResult._({
|
||||
required this.success,
|
||||
this.errorMessage,
|
||||
this.zipBytes,
|
||||
this.movieCount = 0,
|
||||
this.bookCount = 0,
|
||||
this.noteCount = 0,
|
||||
this.imageCount = 0,
|
||||
this.epubCount = 0,
|
||||
});
|
||||
|
||||
factory AutoBackupExportResult.success({
|
||||
required Uint8List zipBytes,
|
||||
required int movieCount,
|
||||
required int bookCount,
|
||||
required int noteCount,
|
||||
required int imageCount,
|
||||
int epubCount = 0,
|
||||
}) {
|
||||
return AutoBackupExportResult._(
|
||||
success: true, zipBytes: zipBytes,
|
||||
movieCount: movieCount, bookCount: bookCount,
|
||||
noteCount: noteCount, imageCount: imageCount,
|
||||
epubCount: epubCount,
|
||||
);
|
||||
}
|
||||
|
||||
factory AutoBackupExportResult.error(String message) {
|
||||
return AutoBackupExportResult._(success: false, errorMessage: message);
|
||||
}
|
||||
}
|
||||
|
||||
class ExportResult {
|
||||
final bool success;
|
||||
final bool cancelled;
|
||||
final String? errorMessage;
|
||||
final String? filePath;
|
||||
final int movieCount;
|
||||
final int bookCount;
|
||||
final int noteCount;
|
||||
final int imageCount;
|
||||
|
||||
ExportResult._({
|
||||
required this.success,
|
||||
this.cancelled = false,
|
||||
this.errorMessage,
|
||||
this.filePath,
|
||||
this.movieCount = 0,
|
||||
this.bookCount = 0,
|
||||
this.noteCount = 0,
|
||||
this.imageCount = 0,
|
||||
});
|
||||
|
||||
factory ExportResult.success({
|
||||
required String filePath,
|
||||
required int movieCount,
|
||||
required int bookCount,
|
||||
required int noteCount,
|
||||
required int imageCount,
|
||||
}) {
|
||||
return ExportResult._(
|
||||
success: true, filePath: filePath,
|
||||
movieCount: movieCount, bookCount: bookCount,
|
||||
noteCount: noteCount, imageCount: imageCount,
|
||||
);
|
||||
}
|
||||
|
||||
factory ExportResult.cancelled() {
|
||||
return ExportResult._(success: false, cancelled: true);
|
||||
}
|
||||
|
||||
factory ExportResult.error(String message) {
|
||||
return ExportResult._(success: false, errorMessage: message);
|
||||
}
|
||||
}
|
||||
|
||||
class ImportResult {
|
||||
final bool success;
|
||||
final bool cancelled;
|
||||
final String? errorMessage;
|
||||
final Map<String, int>? stats;
|
||||
|
||||
ImportResult._({
|
||||
required this.success,
|
||||
this.cancelled = false,
|
||||
this.errorMessage,
|
||||
this.stats,
|
||||
});
|
||||
|
||||
factory ImportResult.success(Map<String, int> stats) {
|
||||
return ImportResult._(success: true, stats: stats);
|
||||
}
|
||||
|
||||
factory ImportResult.cancelled() {
|
||||
return ImportResult._(success: false, cancelled: true);
|
||||
}
|
||||
|
||||
factory ImportResult.error(String message) {
|
||||
return ImportResult._(success: false, errorMessage: message);
|
||||
}
|
||||
|
||||
String get statsText {
|
||||
if (stats == null || stats!.isEmpty) return '没有导入任何数据';
|
||||
return stats!.entries.map((e) => '${e.key}: ${e.value}').join(',');
|
||||
}
|
||||
}
|
||||
585
lib/services/sync/webdav_service.dart
Normal file
585
lib/services/sync/webdav_service.dart
Normal file
@@ -0,0 +1,585 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'backup_service.dart';
|
||||
|
||||
/// WebDAV 同步结果
|
||||
class SyncResult {
|
||||
final bool success;
|
||||
final String message;
|
||||
final DateTime? lastSyncTime;
|
||||
final int uploadedFiles;
|
||||
final int downloadedFiles;
|
||||
final int uploadedImages;
|
||||
final int downloadedImages;
|
||||
final bool needReload;
|
||||
|
||||
SyncResult({
|
||||
required this.success,
|
||||
required this.message,
|
||||
this.lastSyncTime,
|
||||
this.uploadedFiles = 0,
|
||||
this.downloadedFiles = 0,
|
||||
this.uploadedImages = 0,
|
||||
this.downloadedImages = 0,
|
||||
this.needReload = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// 同步方向
|
||||
enum SyncDirection {
|
||||
upload, // 仅上传
|
||||
download, // 仅下载
|
||||
bidirectional, // 双向同步
|
||||
}
|
||||
|
||||
/// WebDAV 服务类 - 完整备份 zip 同步
|
||||
class WebDAVService {
|
||||
static final WebDAVService _instance = WebDAVService._internal();
|
||||
static WebDAVService get instance => _instance;
|
||||
|
||||
WebDAVService._internal();
|
||||
|
||||
static const String _configKey = 'webdav_config';
|
||||
static const String _lastSyncKey = 'webdav_last_sync';
|
||||
static const String _autoSyncKey = 'webdav_auto_sync';
|
||||
static const String _autoSyncIntervalKey = 'webdav_auto_sync_interval';
|
||||
|
||||
// 默认自动同步间隔(分钟)
|
||||
static const int _defaultAutoSyncInterval = 5;
|
||||
|
||||
Map<String, String>? _cachedConfig;
|
||||
Timer? _autoSyncTimer;
|
||||
bool _isAutoSyncEnabled = false;
|
||||
int _autoSyncIntervalMinutes = _defaultAutoSyncInterval;
|
||||
bool _isSyncing = false;
|
||||
|
||||
/// 获取配置
|
||||
Future<Map<String, String>?> getConfig() async {
|
||||
if (_cachedConfig != null) {
|
||||
return _cachedConfig;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final configJson = prefs.getString(_configKey);
|
||||
if (configJson != null) {
|
||||
try {
|
||||
final config = Map<String, String>.from(jsonDecode(configJson));
|
||||
_cachedConfig = config;
|
||||
return config;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 保存配置
|
||||
Future<void> saveConfig({
|
||||
required String url,
|
||||
required String username,
|
||||
required String password,
|
||||
required String path,
|
||||
}) async {
|
||||
final config = {
|
||||
'url': url,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'path': path,
|
||||
};
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_configKey, jsonEncode(config));
|
||||
_cachedConfig = config;
|
||||
}
|
||||
|
||||
/// 清除配置
|
||||
Future<void> clearConfig() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_configKey);
|
||||
await prefs.remove(_lastSyncKey);
|
||||
await prefs.remove(_autoSyncKey);
|
||||
await prefs.remove(_autoSyncIntervalKey);
|
||||
_cachedConfig = null;
|
||||
stopAutoSync();
|
||||
}
|
||||
|
||||
/// 测试连接
|
||||
Future<Map<String, dynamic>> testConnection({
|
||||
required String url,
|
||||
required String username,
|
||||
required String password,
|
||||
required String path,
|
||||
}) async {
|
||||
try {
|
||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
var davUrl = '$baseUrl$path';
|
||||
|
||||
final client = http.Client();
|
||||
try {
|
||||
var propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
||||
propfindRequest.headers['Authorization'] = _basicAuth(username, password);
|
||||
propfindRequest.headers['Depth'] = '0';
|
||||
propfindRequest.body = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
</D:propfind>''';
|
||||
|
||||
var propfindResponse = await client.send(propfindRequest);
|
||||
|
||||
if (propfindResponse.statusCode == 301 ||
|
||||
propfindResponse.statusCode == 302 ||
|
||||
propfindResponse.statusCode == 307 ||
|
||||
propfindResponse.statusCode == 308) {
|
||||
final location = propfindResponse.headers['location'];
|
||||
if (location != null) {
|
||||
davUrl = location;
|
||||
propfindRequest = http.Request('PROPFIND', Uri.parse(davUrl));
|
||||
propfindRequest.headers['Authorization'] = _basicAuth(username, password);
|
||||
propfindRequest.headers['Depth'] = '0';
|
||||
propfindRequest.body = '''<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
</D:propfind>''';
|
||||
propfindResponse = await client.send(propfindRequest);
|
||||
}
|
||||
}
|
||||
|
||||
if (propfindResponse.statusCode == 207) {
|
||||
return {'success': true, 'message': '连接成功'};
|
||||
} else if (propfindResponse.statusCode == 401) {
|
||||
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
||||
} else if (propfindResponse.statusCode == 404) {
|
||||
// 目录不存在,尝试创建
|
||||
} else {
|
||||
return {'success': false, 'message': '服务器返回错误: ${propfindResponse.statusCode}'};
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
final mkcolRequest = http.Request('MKCOL', Uri.parse(davUrl));
|
||||
mkcolRequest.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
final mkcolResponse = await client.send(mkcolRequest);
|
||||
|
||||
if (mkcolResponse.statusCode == 201) {
|
||||
return {'success': true, 'message': '连接成功,已创建目录'};
|
||||
} else if (mkcolResponse.statusCode == 405) {
|
||||
return {'success': true, 'message': '连接成功,目录已存在'};
|
||||
} else if (mkcolResponse.statusCode == 401) {
|
||||
return {'success': false, 'message': '认证失败,请检查用户名和密码'};
|
||||
} else if (mkcolResponse.statusCode == 409) {
|
||||
return {'success': false, 'message': '父目录不存在,请检查路径'};
|
||||
} else {
|
||||
return {'success': false, 'message': '创建目录失败: ${mkcolResponse.statusCode}'};
|
||||
}
|
||||
} catch (e) {
|
||||
return {'success': false, 'message': '连接失败: $e'};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
return {'success': false, 'message': '连接失败: $e'};
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步数据 — 完整备份 zip 格式,与本地备份完全一致
|
||||
Future<SyncResult> syncData({SyncDirection direction = SyncDirection.bidirectional}) async {
|
||||
// 防止并发同步
|
||||
if (_isSyncing) {
|
||||
return SyncResult(success: false, message: '同步正在进行中,请稍后再试');
|
||||
}
|
||||
_isSyncing = true;
|
||||
|
||||
final config = await getConfig();
|
||||
if (config == null) {
|
||||
_isSyncing = false;
|
||||
return SyncResult(success: false, message: '未配置 WebDAV');
|
||||
}
|
||||
|
||||
try {
|
||||
final url = config['url']!;
|
||||
final username = config['username']!;
|
||||
final password = config['password']!;
|
||||
final path = config['path']!;
|
||||
|
||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
||||
|
||||
final client = http.Client();
|
||||
int uploadedFiles = 0;
|
||||
int downloadedFiles = 0;
|
||||
int uploadedImages = 0;
|
||||
int downloadedImages = 0;
|
||||
bool needReload = false;
|
||||
|
||||
try {
|
||||
if (direction == SyncDirection.upload) {
|
||||
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
||||
if (!exportResult.success || exportResult.zipBytes == null) {
|
||||
return SyncResult(success: false, message: exportResult.errorMessage ?? '创建备份失败');
|
||||
}
|
||||
|
||||
final success = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
||||
if (success) {
|
||||
uploadedFiles = 1;
|
||||
uploadedImages = exportResult.imageCount;
|
||||
debugPrint('[WebDAV] 备份上传成功 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
|
||||
} else {
|
||||
return SyncResult(success: false, message: '上传备份文件失败');
|
||||
}
|
||||
|
||||
} else if (direction == SyncDirection.download) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'mooknote_download.zip'));
|
||||
final success = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
|
||||
if (success && await tempZip.exists()) {
|
||||
final bytes = await tempZip.readAsBytes();
|
||||
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
||||
await tempZip.delete();
|
||||
|
||||
if (importResult.success) {
|
||||
downloadedFiles = 1;
|
||||
downloadedImages = importResult.stats?['图片'] ?? 0;
|
||||
needReload = true;
|
||||
debugPrint('[WebDAV] 备份恢复成功: ${importResult.statsText}');
|
||||
} else {
|
||||
return SyncResult(success: false, message: importResult.errorMessage ?? '恢复备份失败');
|
||||
}
|
||||
} else {
|
||||
return SyncResult(success: false, message: '服务器上没有备份文件,请先从其他设备上传');
|
||||
}
|
||||
|
||||
} else if (direction == SyncDirection.bidirectional) {
|
||||
// 获取远程备份的修改时间
|
||||
final remoteModTime = await _getRemoteFileModifiedTime(client, zipUrl, username, password);
|
||||
|
||||
// 获取上次同步时间
|
||||
final syncPrefs = await SharedPreferences.getInstance();
|
||||
final lastSyncStr = syncPrefs.getString(_lastSyncKey);
|
||||
final lastSyncTime = lastSyncStr != null ? DateTime.tryParse(lastSyncStr) : null;
|
||||
|
||||
final bool remoteIsNewer = remoteModTime != null &&
|
||||
(lastSyncTime == null || remoteModTime.isAfter(lastSyncTime));
|
||||
|
||||
if (remoteIsNewer) {
|
||||
// 远程更新,下载并恢复
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempZip = File(p.join(tempDir.path, 'mooknote_bidir.zip'));
|
||||
|
||||
final downloadSuccess = await _downloadFile(client, zipUrl, username, password, tempZip);
|
||||
if (downloadSuccess && await tempZip.exists()) {
|
||||
final bytes = await tempZip.readAsBytes();
|
||||
final importResult = await BackupService.instance.restoreFromZipBytes(bytes);
|
||||
await tempZip.delete();
|
||||
|
||||
if (importResult.success) {
|
||||
downloadedFiles = 1;
|
||||
downloadedImages = importResult.stats?['图片'] ?? 0;
|
||||
needReload = true;
|
||||
debugPrint('[WebDAV] 远程备份较新,已恢复: ${importResult.statsText}');
|
||||
}
|
||||
} else {
|
||||
try { await tempZip.delete(); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
debugPrint('[WebDAV] 本地数据已是最新或远程无更新,跳过下载');
|
||||
}
|
||||
|
||||
// 上传本地备份(无论是否下载,确保远程有最新数据)
|
||||
final exportResult = await BackupService.instance.exportDataForAutoBackup();
|
||||
if (exportResult.success && exportResult.zipBytes != null) {
|
||||
final uploadSuccess = await _uploadBytes(client, zipUrl, username, password, exportResult.zipBytes!);
|
||||
if (uploadSuccess) {
|
||||
uploadedFiles = 1;
|
||||
uploadedImages = exportResult.imageCount;
|
||||
debugPrint('[WebDAV] 本地备份已上传 (影视${exportResult.movieCount} 书籍${exportResult.bookCount} 笔记${exportResult.noteCount} 图片${exportResult.imageCount})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// 仅在上传成功或下载成功时记录同步时间
|
||||
final bool anySuccess = uploadedFiles > 0 || downloadedFiles > 0;
|
||||
if (anySuccess) {
|
||||
await prefs.setString(_lastSyncKey, DateTime.now().toIso8601String());
|
||||
}
|
||||
|
||||
return SyncResult(
|
||||
success: anySuccess,
|
||||
message: anySuccess ? '同步完成' : '同步未完成,未传输任何数据',
|
||||
lastSyncTime: DateTime.now(),
|
||||
uploadedFiles: uploadedFiles,
|
||||
downloadedFiles: downloadedFiles,
|
||||
uploadedImages: uploadedImages,
|
||||
downloadedImages: downloadedImages,
|
||||
needReload: needReload,
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
} catch (e) {
|
||||
return SyncResult(success: false, message: '同步失败: $e');
|
||||
} finally {
|
||||
_isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取自动同步间隔(分钟)
|
||||
Future<int> getAutoSyncInterval() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getInt(_autoSyncIntervalKey) ?? _defaultAutoSyncInterval;
|
||||
}
|
||||
|
||||
/// 设置自动同步间隔(分钟)
|
||||
Future<void> setAutoSyncInterval(int minutes) async {
|
||||
if (minutes < 1) minutes = 1;
|
||||
if (minutes > 60) minutes = 60;
|
||||
|
||||
_autoSyncIntervalMinutes = minutes;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_autoSyncIntervalKey, minutes);
|
||||
|
||||
if (_isAutoSyncEnabled) {
|
||||
await startAutoSync();
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动自动同步
|
||||
Future<void> startAutoSync() async {
|
||||
await stopAutoSync();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_isAutoSyncEnabled = true;
|
||||
_autoSyncIntervalMinutes = await getAutoSyncInterval();
|
||||
await prefs.setBool(_autoSyncKey, true);
|
||||
|
||||
// 立即执行一次同步
|
||||
debugPrint('[WebDAV] 自动同步已启动,间隔 $_autoSyncIntervalMinutes 分钟');
|
||||
await syncData(direction: SyncDirection.bidirectional);
|
||||
|
||||
// 设置定时器
|
||||
_autoSyncTimer = Timer.periodic(
|
||||
Duration(minutes: _autoSyncIntervalMinutes),
|
||||
(timer) async {
|
||||
if (_isAutoSyncEnabled) {
|
||||
try {
|
||||
await syncData(direction: SyncDirection.bidirectional);
|
||||
} catch (e) {
|
||||
debugPrint('[WebDAV] 自动同步异常: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 停止自动同步
|
||||
Future<void> stopAutoSync() async {
|
||||
_autoSyncTimer?.cancel();
|
||||
_autoSyncTimer = null;
|
||||
_isAutoSyncEnabled = false;
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_autoSyncKey, false);
|
||||
}
|
||||
|
||||
/// 检查自动同步状态
|
||||
Future<bool> isAutoSyncEnabled() async {
|
||||
if (_autoSyncTimer != null) {
|
||||
return _isAutoSyncEnabled;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_autoSyncKey) ?? false;
|
||||
}
|
||||
|
||||
/// 获取上次同步时间
|
||||
Future<String?> getLastSyncTime() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_lastSyncKey);
|
||||
}
|
||||
|
||||
/// 上传字节数据到 WebDAV
|
||||
Future<bool> _uploadBytes(
|
||||
http.Client client,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
Uint8List bytes,
|
||||
) async {
|
||||
try {
|
||||
var request = http.Request('PUT', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Content-Type'] = 'application/zip';
|
||||
request.bodyBytes = bytes;
|
||||
|
||||
var response = await client.send(request);
|
||||
|
||||
// 处理重定向
|
||||
if (response.statusCode == 301 || response.statusCode == 302 ||
|
||||
response.statusCode == 307 || response.statusCode == 308) {
|
||||
final location = response.headers['location'];
|
||||
if (location != null) {
|
||||
request = http.Request('PUT', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
request.headers['Content-Type'] = 'application/zip';
|
||||
request.bodyBytes = bytes;
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[WebDAV] PUT $url -> ${response.statusCode}');
|
||||
return response.statusCode == 200 || response.statusCode == 201 || response.statusCode == 204;
|
||||
} catch (e) {
|
||||
debugPrint('[WebDAV] _uploadBytes error: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载文件到本地
|
||||
Future<bool> _downloadFile(
|
||||
http.Client client,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
File localFile,
|
||||
) async {
|
||||
try {
|
||||
var request = http.Request('GET', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
var response = await client.send(request);
|
||||
|
||||
// 处理重定向
|
||||
if (response.statusCode == 301 || response.statusCode == 302 ||
|
||||
response.statusCode == 307 || response.statusCode == 308) {
|
||||
final location = response.headers['location'];
|
||||
if (location != null) {
|
||||
request = http.Request('GET', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('[WebDAV] GET $url -> ${response.statusCode}');
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
await localFile.parent.create(recursive: true);
|
||||
final bytes = await response.stream.toBytes();
|
||||
await localFile.writeAsBytes(bytes);
|
||||
debugPrint('[WebDAV] Downloaded ${bytes.length} bytes');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取远程备份文件信息(修改时间和大小)
|
||||
Future<Map<String, dynamic>?> getRemoteBackupInfo() async {
|
||||
final config = await getConfig();
|
||||
if (config == null) return null;
|
||||
|
||||
final url = config['url']!;
|
||||
final username = config['username']!;
|
||||
final password = config['password']!;
|
||||
final path = config['path']!;
|
||||
|
||||
final baseUrl = url.endsWith('/') ? url.substring(0, url.length - 1) : url;
|
||||
final zipUrl = '$baseUrl$path/mooknote_backup.zip';
|
||||
|
||||
final client = http.Client();
|
||||
try {
|
||||
var request = http.Request('HEAD', Uri.parse(zipUrl));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
var response = await client.send(request);
|
||||
|
||||
// 处理重定向
|
||||
if (response.statusCode == 301 || response.statusCode == 302 ||
|
||||
response.statusCode == 307 || response.statusCode == 308) {
|
||||
final location = response.headers['location'];
|
||||
if (location != null) {
|
||||
request = http.Request('HEAD', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final lastModified = response.headers['last-modified'];
|
||||
final contentLength = response.headers['content-length'];
|
||||
DateTime? modifiedTime;
|
||||
if (lastModified != null) {
|
||||
modifiedTime = HttpDate.parse(lastModified).toLocal();
|
||||
}
|
||||
return {
|
||||
'modifiedTime': modifiedTime,
|
||||
'size': contentLength != null ? int.tryParse(contentLength) : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[WebDAV] 获取远程备份信息失败: $e');
|
||||
return null;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
Future<DateTime?> _getRemoteFileModifiedTime(
|
||||
http.Client client,
|
||||
String url,
|
||||
String username,
|
||||
String password,
|
||||
) async {
|
||||
try {
|
||||
var request = http.Request('HEAD', Uri.parse(url));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
|
||||
var response = await client.send(request);
|
||||
|
||||
// 处理重定向
|
||||
if (response.statusCode == 301 || response.statusCode == 302 ||
|
||||
response.statusCode == 307 || response.statusCode == 308) {
|
||||
final location = response.headers['location'];
|
||||
if (location != null) {
|
||||
request = http.Request('HEAD', Uri.parse(location));
|
||||
request.headers['Authorization'] = _basicAuth(username, password);
|
||||
response = await client.send(request);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final lastModified = response.headers['last-modified'];
|
||||
if (lastModified != null) {
|
||||
return HttpDate.parse(lastModified);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('[WebDAV] 获取远程文件时间失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Basic Auth 编码
|
||||
String _basicAuth(String username, String password) {
|
||||
final credentials = base64Encode(utf8.encode('$username:$password'));
|
||||
return 'Basic $credentials';
|
||||
}
|
||||
}
|
||||
159
lib/services/usage_stats_service.dart
Normal file
159
lib/services/usage_stats_service.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'user_prefs.dart';
|
||||
import 'server_config.dart';
|
||||
|
||||
/// 匿名用户统计服务(静默运行,对用户不可见)
|
||||
///
|
||||
/// App 启动后每 5 分钟向统计服务器发送匿名心跳。
|
||||
/// 统计数据在服务端管理后台查看,App 内无入口。
|
||||
class UsageStatsService with WidgetsBindingObserver {
|
||||
static final UsageStatsService instance = UsageStatsService._();
|
||||
UsageStatsService._();
|
||||
|
||||
final UserPrefs _prefs = UserPrefs();
|
||||
|
||||
/// 统计服务器地址,debug 走局域网,release 走线上
|
||||
static String serverUrl = '${ServerConfig.baseUrl}/';
|
||||
Timer? _heartbeatTimer;
|
||||
bool _started = false;
|
||||
|
||||
static const _heartbeatInterval = Duration(minutes: 1);
|
||||
|
||||
/// 启动统计服务(App 启动时调用一次)
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
|
||||
// 未配置服务器地址则直接跳过
|
||||
if (serverUrl.isEmpty) return;
|
||||
|
||||
// 首次启动生成匿名设备ID
|
||||
await _ensureDeviceId();
|
||||
|
||||
// 注册生命周期监听
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// 立即发送一次心跳
|
||||
await _sendHeartbeat();
|
||||
|
||||
// 启动定时心跳
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
/// 停止统计服务
|
||||
Future<void> stop() async {
|
||||
if (!_started) return;
|
||||
_started = false;
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
|
||||
// ─── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// 确保设备有匿名ID
|
||||
Future<void> _ensureDeviceId() async {
|
||||
if (_prefs.deviceId.isEmpty) {
|
||||
final id = await _generateDeviceId();
|
||||
await _prefs.setDeviceId(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于设备硬件信息生成匿名设备标识(SHA-256 哈希)
|
||||
Future<String> _generateDeviceId() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
String rawId;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final info = await deviceInfo.androidInfo;
|
||||
rawId = info.id; // Settings.Secure.ANDROID_ID
|
||||
} else if (Platform.isIOS) {
|
||||
final info = await deviceInfo.iosInfo;
|
||||
rawId = info.identifierForVendor ?? '';
|
||||
} else if (Platform.isWindows) {
|
||||
final info = await deviceInfo.windowsInfo;
|
||||
rawId = '${info.computerName}-${info.numberOfCores}';
|
||||
} else if (Platform.isMacOS) {
|
||||
final info = await deviceInfo.macOsInfo;
|
||||
rawId = '${info.computerName}-${info.systemGUID ?? ''}';
|
||||
} else if (Platform.isLinux) {
|
||||
final info = await deviceInfo.linuxInfo;
|
||||
rawId = '${info.name}-${info.id}';
|
||||
} else {
|
||||
rawId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
}
|
||||
|
||||
final bytes = utf8.encode(rawId);
|
||||
final hash = sha256.convert(bytes);
|
||||
final hex = hash.toString();
|
||||
|
||||
// 格式化为 UUID 样式
|
||||
return '${hex.substring(0, 8)}-'
|
||||
'${hex.substring(8, 12)}-'
|
||||
'${hex.substring(12, 16)}-'
|
||||
'${hex.substring(16, 20)}-'
|
||||
'${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
/// 发送心跳
|
||||
Future<void> _sendHeartbeat() async {
|
||||
if (serverUrl.isEmpty) return;
|
||||
final deviceId = _prefs.deviceId;
|
||||
if (deviceId.isEmpty) return;
|
||||
|
||||
try {
|
||||
String appVersion = '';
|
||||
try {
|
||||
final pkgInfo = await PackageInfo.fromPlatform();
|
||||
appVersion = '${pkgInfo.version}+${pkgInfo.buildNumber}';
|
||||
} catch (_) {}
|
||||
|
||||
await http
|
||||
.post(
|
||||
Uri.parse('$serverUrl/api/heartbeat'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'device_hash': deviceId,
|
||||
'device_type':
|
||||
Platform.operatingSystem, // android/ios/windows/macos/linux
|
||||
'device_name':
|
||||
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
|
||||
'app_version': appVersion,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
} catch (_) {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动定时心跳
|
||||
void _startTimer() {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) {
|
||||
_sendHeartbeat();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 生命周期 ──────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// 回到前台:立即发送心跳,恢复定时器
|
||||
_sendHeartbeat();
|
||||
_startTimer();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
// 进入后台:停止定时器
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user