Skip to content

Commit a65ee53

Browse files
TW-2912 Allow mobile clear cache (#2917)
1 parent 2db5910 commit a65ee53

20 files changed

+1194
-12
lines changed

assets/images/data-and-storage.svg

Lines changed: 212 additions & 0 deletions
Loading

assets/l10n/intl_en.arb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3141,6 +3141,41 @@
31413141
"leaveChat": "Leave chat",
31423142
"leaveChatDescription": "Messaging history will still be available to the other person.",
31433143
"leaveChatTitle": "Are you sure you want to leave this chat?",
3144+
"dataAndStorage": "Data and storage",
3145+
"phoneStorage": "Phone Storage",
3146+
"@phoneStorage": {},
3147+
"phoneStorageDescription": "Twake Chat is using {percent} of storage on your phone",
3148+
"@phoneStorageDescription": {
3149+
"placeholders": {
3150+
"percent": {
3151+
"type": "String"
3152+
}
3153+
}
3154+
},
3155+
"medias": "Medias",
3156+
"@medias": {},
3157+
"videos": "Videos",
3158+
"@videos": {},
3159+
"stickersAndEmojis": "Stickers and emojis",
3160+
"@stickersAndEmojis": {},
3161+
"clearCacheSize": "Clear cache {size}",
3162+
"@clearCacheSize": {
3163+
"placeholders": {
3164+
"size": {
3165+
"type": "String"
3166+
}
3167+
}
3168+
},
3169+
"available": "Available",
3170+
"@available": {},
3171+
"clearCacheConfirmTitle": "Clear cache?",
3172+
"@clearCacheConfirmTitle": {},
3173+
"clearCacheConfirmMessage": "This will delete all temporary files stored on your device. This action cannot be undone.",
3174+
"@clearCacheConfirmMessage": {},
3175+
"cacheClearedSuccessfully": "Cache cleared successfully",
3176+
"@cacheClearedSuccessfully": {},
3177+
"cacheIsScanning": "Cache is scanning. Please wait...",
3178+
"@cacheIsScanning": {},
31443179
"selectYourServer": "Select your server",
31453180
"welcomeBack": "Welcome back",
31463181
"matrixIdOrUsername": "Matrix Id or username"

lib/config/go_routes/app_route_paths.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
abstract class AppRoutePaths {
66
static const matrixId = 'matrixId';
77

8+
// Data and Storage routes
9+
static const String dataAndStorageSegment = 'dataAndStorage';
10+
static const String dataAndStorageFull = '/rooms/dataAndStorage';
11+
812
// Security routes
913
static const String roomsSecurityFull = '/rooms/security';
1014
static const String contactsVisibilitySegment = 'contactsVisibility';

lib/config/go_routes/go_router.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import 'package:fluffychat/pages/settings_dashboard/settings_emotes/settings_emo
4646
import 'package:fluffychat/pages/settings_dashboard/settings_notifications/settings_notifications.dart';
4747
import 'package:fluffychat/pages/settings_dashboard/settings_security/settings_security.dart';
4848
import 'package:fluffychat/pages/settings_dashboard/settings_stories/settings_stories.dart';
49+
import 'package:fluffychat/pages/settings_dashboard/settings_data_and_storage/settings_data_and_storage.dart';
4950
import 'package:fluffychat/pages/settings_dashboard/settings_style/settings_style.dart';
5051
import 'package:fluffychat/pages/sign_up/signup.dart';
5152
import 'package:fluffychat/widgets/layouts/agruments/app_adaptive_scaffold_body_args.dart';
@@ -351,6 +352,13 @@ abstract class AppRoutes {
351352
defaultPageBuilder(context, const SettingsAppLanguage()),
352353
redirect: loggedOutRedirect,
353354
),
355+
if (PlatformInfos.isMobile)
356+
GoRoute(
357+
path: AppRoutePaths.dataAndStorageSegment,
358+
pageBuilder: (context, state) =>
359+
defaultPageBuilder(context, const SettingsDataAndStorage()),
360+
redirect: loggedOutRedirect,
361+
),
354362
GoRoute(
355363
path: 'chat',
356364
pageBuilder: (context, state) =>

lib/di/global/get_it_initializer.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ import 'package:fluffychat/modules/federation_identity_lookup/manager/federation
148148
import 'package:fluffychat/modules/federation_identity_lookup/manager/identity_lookup_manager.dart';
149149
import 'package:fluffychat/modules/federation_identity_request_token/manager/federation_identity_request_token_manager.dart';
150150
import 'package:fluffychat/pages/chat/chat_pinned_events/pinned_events_controller.dart';
151+
import 'package:fluffychat/pages/settings_dashboard/settings_data_and_storage/isolate_storage_scanner_service.dart';
152+
import 'package:fluffychat/pages/settings_dashboard/settings_data_and_storage/settings_data_and_storage_scanner_service.dart';
151153
import 'package:fluffychat/utils/famedlysdk_store.dart';
152154
import 'package:fluffychat/utils/logging/loggers/console_logger.dart';
153155
import 'package:fluffychat/utils/logging/loggers/sentry_logger.dart';
@@ -198,6 +200,9 @@ class GetItInitializer {
198200
NetworkConnectivityDI().bind();
199201
getIt.registerSingleton(ResponsiveUtils());
200202
getIt.registerSingleton(TwakeEventDispatcher());
203+
getIt.registerLazySingleton<StorageScannerService>(
204+
() => const IsolateStorageScannerService(),
205+
);
201206
getIt.registerSingleton(Store());
202207
getIt.registerFactory<AppConfigLoader>(() => AppConfigLoader());
203208
bindingCachingManager();

lib/domain/model/preview_file/supported_preview_file_types.dart

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,7 @@ class SupportedPreviewFileTypes {
194194

195195
static const supportAnotherTypes = ['application/octet-stream'];
196196

197-
static const docFileTypes = [
198-
'docx',
199-
'doc',
200-
'docx',
201-
'docm',
202-
'dot',
203-
'dotx',
204-
'dotm',
205-
];
197+
static const docFileTypes = ['doc', 'docx', 'docm', 'dot', 'dotx', 'dotm'];
206198

207199
static const pdfFileTypes = ['pdf'];
208200

lib/pages/settings_dashboard/settings/settings.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:async';
22

33
import 'package:fluffychat/config/app_config.dart';
4+
import 'package:fluffychat/config/go_routes/app_route_paths.dart';
45
import 'package:fluffychat/di/global/get_it_initializer.dart';
56
import 'package:fluffychat/domain/model/extensions/common_settings/common_settings_extensions.dart';
67
import 'package:fluffychat/domain/model/extensions/homeserver_summary_extensions.dart';
@@ -57,6 +58,7 @@ class SettingsController extends State<Settings> with ConnectPageMixin {
5758
SettingEnum.notificationAndSounds,
5859
SettingEnum.appLanguage,
5960
SettingEnum.devices,
61+
if (PlatformInfos.isMobile) SettingEnum.dataAndStorage,
6062
SettingEnum.help,
6163
SettingEnum.about,
6264
SettingEnum.logout,
@@ -260,6 +262,12 @@ class SettingsController extends State<Settings> with ConnectPageMixin {
260262
optionsSelectNotifier.value = null;
261263
}
262264
break;
265+
case SettingEnum.dataAndStorage:
266+
final result = await context.push(AppRoutePaths.dataAndStorageFull);
267+
if (result == null) {
268+
optionsSelectNotifier.value = null;
269+
}
270+
break;
263271
case SettingEnum.help:
264272
UrlLauncher(context, url: AppConfig.supportUrl).openUrlInAppBrowser();
265273
break;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import 'dart:io';
2+
import 'dart:isolate';
3+
4+
import 'package:async/async.dart';
5+
6+
import 'settings_data_and_storage_constants.dart';
7+
import 'settings_data_and_storage_scanner_service.dart';
8+
9+
void _isolateScannerEntryPoint(SendPort sendPort) async {
10+
final receivePort = ReceivePort();
11+
sendPort.send(receivePort.sendPort);
12+
final String dirPath = await receivePort.first as String;
13+
receivePort.close();
14+
15+
final Map<String, int> result = {
16+
for (final c in StorageCategory.values) c.name: 0,
17+
};
18+
19+
try {
20+
final dir = Directory(dirPath);
21+
await for (final entity in dir.list(recursive: true, followLinks: false)) {
22+
if (entity is! File) continue;
23+
try {
24+
final int size = entity.lengthSync();
25+
final StorageCategory category = StorageCategory.fromFile(entity.path);
26+
result[category.name] = (result[category.name] ?? 0) + size;
27+
} catch (_) {
28+
// Swallow: caller catches errors thrown by [scanDirectory] and logs them.
29+
}
30+
}
31+
} catch (_) {
32+
// Swallow: caller catches errors thrown by [scanDirectory] and logs them.
33+
}
34+
35+
sendPort.send(result);
36+
}
37+
38+
Future<Map<StorageCategory, int>> _scanDirectory(String dirPath) async {
39+
final receivePort = ReceivePort();
40+
await Isolate.spawn(_isolateScannerEntryPoint, receivePort.sendPort);
41+
42+
final events = StreamQueue(receivePort);
43+
final SendPort isolateSendPort = await events.next as SendPort;
44+
isolateSendPort.send(dirPath);
45+
46+
// Convert from wire format (String keys) back to typed enum keys.
47+
final Map<String, int> raw = Map<String, int>.from(await events.next as Map);
48+
events.cancel();
49+
receivePort.close();
50+
51+
return {for (final c in StorageCategory.values) c: raw[c.name] ?? 0};
52+
}
53+
54+
class IsolateStorageScannerService implements StorageScannerService {
55+
const IsolateStorageScannerService();
56+
57+
@override
58+
Future<Map<StorageCategory, int>> scanDirectory(String dirPath) =>
59+
_scanDirectory(dirPath);
60+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import 'dart:io';
2+
3+
import 'package:fluffychat/generated/l10n/app_localizations.dart';
4+
import 'package:fluffychat/utils/dialog/twake_dialog.dart';
5+
import 'package:fluffychat/utils/twake_snackbar.dart';
6+
import 'package:flutter/material.dart';
7+
import 'package:matrix/matrix.dart';
8+
import 'package:path_provider/path_provider.dart';
9+
import 'package:storage_space/storage_space.dart';
10+
11+
import 'package:fluffychat/di/global/get_it_initializer.dart';
12+
13+
import 'settings_data_and_storage_calculator.dart';
14+
import 'settings_data_and_storage_constants.dart';
15+
import 'settings_data_and_storage_scanner_service.dart';
16+
import 'settings_data_and_storage_view.dart';
17+
18+
class SettingsDataAndStorage extends StatefulWidget {
19+
const SettingsDataAndStorage({super.key});
20+
21+
@override
22+
SettingsDataAndStorageController createState() =>
23+
SettingsDataAndStorageController();
24+
}
25+
26+
class SettingsDataAndStorageController extends State<SettingsDataAndStorage> {
27+
final StorageScannerService _scannerService = getIt
28+
.get<StorageScannerService>();
29+
30+
double _totalStorageBytes = 0;
31+
32+
StorageScanResult _scanResult = const StorageScanResult();
33+
bool _isScanning = true;
34+
35+
bool get isScanning => _isScanning;
36+
double get totalCacheBytes => _scanResult.totalBytes.toDouble();
37+
38+
double get storageUsageRatio =>
39+
StorageCalculator.usageRatio(totalCacheBytes, _totalStorageBytes);
40+
41+
String get storageUsagePercent =>
42+
StorageCalculator.usagePercent(totalCacheBytes, _totalStorageBytes);
43+
44+
String formattedBytesFor(StorageCategory category) =>
45+
StorageCalculator.formatBytes(_scanResult.bytesFor(category).toDouble());
46+
47+
String get formattedTotalCache =>
48+
StorageCalculator.formatBytes(totalCacheBytes);
49+
50+
@override
51+
void initState() {
52+
super.initState();
53+
_initDataAndStorage();
54+
}
55+
56+
Future<void> _initDataAndStorage() async {
57+
try {
58+
_totalStorageBytes = await _getTotalStorageBytes();
59+
_scanResult = await _getCacheCategories();
60+
} catch (e, s) {
61+
Logs().wtf('SettingsDataAndStorage:_initDataAndStorage', e, s);
62+
} finally {
63+
_isScanning = false;
64+
}
65+
if (mounted) setState(() {});
66+
}
67+
68+
Future<double> _getTotalStorageBytes() async {
69+
try {
70+
final storageSpace = await getStorageSpace(
71+
lowOnSpaceThreshold: 0,
72+
fractionDigits: 0,
73+
);
74+
return storageSpace.total.toDouble();
75+
} catch (e, s) {
76+
Logs().e('SettingsDataAndStorage::_getTotalStorageBytes', e, s);
77+
rethrow;
78+
}
79+
}
80+
81+
Future<StorageScanResult> _getCacheCategories() async {
82+
try {
83+
final Directory tempDir = await getTemporaryDirectory();
84+
final Map<StorageCategory, int> result = await _scannerService
85+
.scanDirectory(tempDir.path);
86+
return StorageScanResult(result);
87+
} catch (e, s) {
88+
Logs().e('SettingsDataAndStorage::_getCacheCategories', e, s);
89+
rethrow;
90+
}
91+
}
92+
93+
Future<void> onClearCache(BuildContext context) async {
94+
final l10n = L10n.of(context)!;
95+
if (_isScanning) {
96+
TwakeSnackBar.show(context, l10n.cacheIsScanning);
97+
return;
98+
}
99+
final confirmed = await showConfirmAlertDialog(
100+
context: context,
101+
title: l10n.clearCacheConfirmTitle,
102+
message: l10n.clearCacheConfirmMessage,
103+
okLabel: l10n.ok,
104+
cancelLabel: l10n.cancel,
105+
);
106+
if (confirmed != ConfirmResult.ok) return;
107+
108+
await _clearCacheDir(context);
109+
await _rescanCache();
110+
}
111+
112+
Future<void> _clearCacheDir(BuildContext context) async {
113+
final l10n = L10n.of(context)!;
114+
try {
115+
final Directory tempDir = await getTemporaryDirectory();
116+
if (tempDir.existsSync()) {
117+
await for (final entity in tempDir.list()) {
118+
await entity.delete(recursive: true);
119+
}
120+
}
121+
if (context.mounted) {
122+
TwakeSnackBar.show(context, l10n.cacheClearedSuccessfully);
123+
}
124+
} catch (e, s) {
125+
Logs().e('SettingsDataAndStorage:_clearCacheDir', e, s);
126+
}
127+
128+
if (!context.mounted) return;
129+
setState(() {
130+
_scanResult = const StorageScanResult();
131+
_isScanning = true;
132+
});
133+
}
134+
135+
Future<void> _rescanCache() async {
136+
try {
137+
_scanResult = await _getCacheCategories();
138+
} catch (e, s) {
139+
Logs().e('SettingsDataAndStorage:_rescanCache', e, s);
140+
}
141+
_isScanning = false;
142+
if (context.mounted) setState(() {});
143+
}
144+
145+
@override
146+
Widget build(BuildContext context) => SettingsDataAndStorageView(this);
147+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import 'settings_data_and_storage_constants.dart';
2+
3+
class StorageScanResult {
4+
final Map<StorageCategory, int> _bytes;
5+
6+
const StorageScanResult([this._bytes = const <StorageCategory, int>{}]);
7+
8+
int bytesFor(StorageCategory category) => _bytes[category] ?? 0;
9+
10+
int get totalBytes => _bytes.values.fold(0, (sum, v) => sum + v);
11+
}
12+
13+
class StorageCalculator {
14+
const StorageCalculator._();
15+
16+
static String formatBytes(double bytes) {
17+
if (bytes >= 1024 * 1024 * 1024) {
18+
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
19+
}
20+
if (bytes >= 1024 * 1024) {
21+
return '${(bytes / (1024 * 1024)).toStringAsFixed(0)} MB';
22+
}
23+
if (bytes >= 1024) {
24+
return '${(bytes / 1024).toStringAsFixed(0)} KB';
25+
}
26+
return '${bytes.toStringAsFixed(0)} B';
27+
}
28+
29+
/// Ratio of [cacheBytes] to device [totalStorageBytes], clamped to [0, 1].
30+
///
31+
/// Returns 0.0 when [totalStorageBytes] is unknown (≤ 0) to avoid
32+
/// division-by-zero or a ratio > 1.
33+
static double usageRatio(double cacheBytes, double totalStorageBytes) {
34+
if (totalStorageBytes <= 0) return 0.0;
35+
return (cacheBytes / totalStorageBytes).clamp(0.0, 1.0);
36+
}
37+
38+
static String usagePercent(double cacheBytes, double totalStorageBytes) {
39+
final ratio = usageRatio(cacheBytes, totalStorageBytes);
40+
return '${(ratio * 100).toStringAsFixed(1)}%';
41+
}
42+
}

0 commit comments

Comments
 (0)