Skip to content

Commit d58be88

Browse files
committed
feat: キャッシュ機能付きダウンロードサービスを追加し、ストレージ権限を不要にする
1 parent 2812125 commit d58be88

File tree

7 files changed

+683
-320
lines changed

7 files changed

+683
-320
lines changed

lib/screens/browser_screen.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
99
import 'package:webview_flutter/webview_flutter.dart';
1010
import 'package:favicon/favicon.dart';
1111
import '../providers/theme_provider.dart';
12+
import '../services/cached_download_service.dart'; // キャッシュ機能付きダウンロードサービス
1213
// For fetching favicon image
1314

1415
// Helper function to determine text color based on background
@@ -36,6 +37,9 @@ class _BrowserScreenState extends State<BrowserScreen> {
3637
String _currentUrl = '';
3738
bool _isLoadingWebView = false;
3839

40+
// キャッシュ機能付きダウンロードサービス
41+
final CachedDownloadService _cachedDownloadService = CachedDownloadService();
42+
3943
// Default sites
4044
final String _noviStepsUrl = 'https://atcoder-novisteps.vercel.app/problems';
4145
final String _noviStepsTitle = 'NoviSteps';

lib/services/cache_manager.dart

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import 'dart:io';
2+
import 'dart:convert';
3+
import 'dart:developer' as developer;
4+
import 'package:path_provider/path_provider.dart';
5+
import 'package:crypto/crypto.dart';
6+
7+
/// キャッシュ管理サービス
8+
/// アプリ内部ストレージを使用することで、外部ストレージ権限を不要にする
9+
class CacheManager {
10+
static const String _cacheDir = 'app_cache';
11+
static const String _metadataFile = 'cache_metadata.json';
12+
static const int _maxCacheAgeHours = 24; // キャッシュの有効期限(時間)
13+
14+
/// アプリ内部のキャッシュディレクトリを取得
15+
/// 権限不要で使用可能
16+
Future<Directory> _getCacheDirectory() async {
17+
final Directory appDir = await getApplicationDocumentsDirectory();
18+
final Directory cacheDir = Directory('${appDir.path}/$_cacheDir');
19+
20+
if (!await cacheDir.exists()) {
21+
await cacheDir.create(recursive: true);
22+
}
23+
24+
return cacheDir;
25+
}
26+
27+
/// キャッシュメタデータファイルのパスを取得
28+
Future<File> _getMetadataFile() async {
29+
final Directory cacheDir = await _getCacheDirectory();
30+
return File('${cacheDir.path}/$_metadataFile');
31+
}
32+
33+
/// キャッシュメタデータを読み込む
34+
Future<Map<String, dynamic>> _loadMetadata() async {
35+
try {
36+
final File metadataFile = await _getMetadataFile();
37+
if (await metadataFile.exists()) {
38+
final String content = await metadataFile.readAsString();
39+
return Map<String, dynamic>.from(jsonDecode(content));
40+
}
41+
} catch (e) {
42+
developer.log('Error loading cache metadata: $e', name: 'CacheManager');
43+
}
44+
return {};
45+
}
46+
47+
/// キャッシュメタデータを保存
48+
Future<void> _saveMetadata(Map<String, dynamic> metadata) async {
49+
try {
50+
final File metadataFile = await _getMetadataFile();
51+
await metadataFile.writeAsString(jsonEncode(metadata));
52+
} catch (e) {
53+
developer.log('Error saving cache metadata: $e', name: 'CacheManager');
54+
}
55+
}
56+
57+
/// URLからキャッシュキーを生成
58+
String _generateCacheKey(String url) {
59+
final bytes = utf8.encode(url);
60+
final digest = sha256.convert(bytes);
61+
return digest.toString();
62+
}
63+
64+
/// ファイルがキャッシュに存在するかチェック
65+
Future<File?> getCachedFile(String url) async {
66+
try {
67+
final String cacheKey = _generateCacheKey(url);
68+
final Directory cacheDir = await _getCacheDirectory();
69+
final File cachedFile = File('${cacheDir.path}/$cacheKey');
70+
71+
if (await cachedFile.exists()) {
72+
// キャッシュの有効期限をチェック
73+
final Map<String, dynamic> metadata = await _loadMetadata();
74+
final Map<String, dynamic>? fileMetadata = metadata[cacheKey];
75+
76+
if (fileMetadata != null) {
77+
final DateTime cachedAt = DateTime.parse(fileMetadata['cachedAt']);
78+
final DateTime expireAt = cachedAt.add(Duration(hours: _maxCacheAgeHours));
79+
80+
if (DateTime.now().isBefore(expireAt)) {
81+
developer.log('Cache hit for URL: $url', name: 'CacheManager');
82+
return cachedFile;
83+
} else {
84+
// 期限切れのキャッシュを削除
85+
await _deleteCachedFile(cacheKey);
86+
developer.log('Cache expired for URL: $url', name: 'CacheManager');
87+
}
88+
}
89+
}
90+
91+
developer.log('Cache miss for URL: $url', name: 'CacheManager');
92+
return null;
93+
} catch (e) {
94+
developer.log('Error checking cache for URL $url: $e', name: 'CacheManager');
95+
return null;
96+
}
97+
}
98+
99+
/// ファイルをキャッシュに保存
100+
Future<File?> cacheFile(String url, List<int> data, {String? originalFileName}) async {
101+
try {
102+
final String cacheKey = _generateCacheKey(url);
103+
final Directory cacheDir = await _getCacheDirectory();
104+
final File cachedFile = File('${cacheDir.path}/$cacheKey');
105+
106+
// ファイルを保存
107+
await cachedFile.writeAsBytes(data);
108+
109+
// メタデータを更新
110+
final Map<String, dynamic> metadata = await _loadMetadata();
111+
metadata[cacheKey] = {
112+
'url': url,
113+
'cachedAt': DateTime.now().toIso8601String(),
114+
'originalFileName': originalFileName,
115+
'fileSize': data.length,
116+
};
117+
await _saveMetadata(metadata);
118+
119+
developer.log('File cached successfully: $url', name: 'CacheManager');
120+
return cachedFile;
121+
} catch (e) {
122+
developer.log('Error caching file for URL $url: $e', name: 'CacheManager');
123+
return null;
124+
}
125+
}
126+
127+
/// 特定のキャッシュファイルを削除
128+
Future<void> _deleteCachedFile(String cacheKey) async {
129+
try {
130+
final Directory cacheDir = await _getCacheDirectory();
131+
final File cachedFile = File('${cacheDir.path}/$cacheKey');
132+
133+
if (await cachedFile.exists()) {
134+
await cachedFile.delete();
135+
}
136+
137+
// メタデータからも削除
138+
final Map<String, dynamic> metadata = await _loadMetadata();
139+
metadata.remove(cacheKey);
140+
await _saveMetadata(metadata);
141+
} catch (e) {
142+
developer.log('Error deleting cached file $cacheKey: $e', name: 'CacheManager');
143+
}
144+
}
145+
146+
/// 期限切れのキャッシュをクリア
147+
Future<void> clearExpiredCache() async {
148+
try {
149+
final Map<String, dynamic> metadata = await _loadMetadata();
150+
final DateTime now = DateTime.now();
151+
final List<String> expiredKeys = [];
152+
153+
for (final String key in metadata.keys) {
154+
final Map<String, dynamic>? fileMetadata = metadata[key];
155+
if (fileMetadata != null) {
156+
final DateTime cachedAt = DateTime.parse(fileMetadata['cachedAt']);
157+
final DateTime expireAt = cachedAt.add(Duration(hours: _maxCacheAgeHours));
158+
159+
if (now.isAfter(expireAt)) {
160+
expiredKeys.add(key);
161+
}
162+
}
163+
}
164+
165+
// 期限切れのファイルを削除
166+
for (final String key in expiredKeys) {
167+
await _deleteCachedFile(key);
168+
}
169+
170+
developer.log('Cleared ${expiredKeys.length} expired cache files', name: 'CacheManager');
171+
} catch (e) {
172+
developer.log('Error clearing expired cache: $e', name: 'CacheManager');
173+
}
174+
}
175+
176+
/// 全キャッシュをクリア
177+
Future<void> clearAllCache() async {
178+
try {
179+
final Directory cacheDir = await _getCacheDirectory();
180+
if (await cacheDir.exists()) {
181+
await cacheDir.delete(recursive: true);
182+
}
183+
developer.log('All cache cleared', name: 'CacheManager');
184+
} catch (e) {
185+
developer.log('Error clearing all cache: $e', name: 'CacheManager');
186+
}
187+
}
188+
189+
/// キャッシュサイズを取得
190+
Future<int> getCacheSize() async {
191+
try {
192+
final Directory cacheDir = await _getCacheDirectory();
193+
if (!await cacheDir.exists()) {
194+
return 0;
195+
}
196+
197+
int totalSize = 0;
198+
await for (final FileSystemEntity entity in cacheDir.list(recursive: true)) {
199+
if (entity is File) {
200+
final int fileSize = await entity.length();
201+
totalSize += fileSize;
202+
}
203+
}
204+
205+
return totalSize;
206+
} catch (e) {
207+
developer.log('Error calculating cache size: $e', name: 'CacheManager');
208+
return 0;
209+
}
210+
}
211+
212+
/// キャッシュ統計を取得
213+
Future<Map<String, dynamic>> getCacheStats() async {
214+
try {
215+
final Map<String, dynamic> metadata = await _loadMetadata();
216+
final int cacheSize = await getCacheSize();
217+
final int fileCount = metadata.length;
218+
219+
return {
220+
'fileCount': fileCount,
221+
'totalSize': cacheSize,
222+
'totalSizeMB': (cacheSize / (1024 * 1024)).toStringAsFixed(2),
223+
};
224+
} catch (e) {
225+
developer.log('Error getting cache stats: $e', name: 'CacheManager');
226+
return {
227+
'fileCount': 0,
228+
'totalSize': 0,
229+
'totalSizeMB': '0.00',
230+
};
231+
}
232+
}
233+
}

0 commit comments

Comments
 (0)