import "dart:async"; import "dart:convert"; import "dart:io"; import "package:computer/computer.dart"; import "package:flutter/foundation.dart"; import "package:flutter/widgets.dart"; import "package:logging/logging.dart"; import "package:path_provider/path_provider.dart"; import "package:photos/core/event_bus.dart"; import "package:photos/events/file_uploaded_event.dart"; import "package:photos/events/magic_cache_updated_event.dart"; import "package:photos/extensions/stop_watch.dart"; import "package:photos/l10n/l10n.dart"; import "package:photos/models/file/extensions/file_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/ml/discover/prompt.dart"; import "package:photos/models/search/generic_search_result.dart"; import "package:photos/models/search/search_types.dart"; import "package:photos/service_locator.dart"; import "package:photos/services/machine_learning/semantic_search/semantic_search_service.dart"; import "package:photos/services/remote_assets_service.dart"; import "package:photos/services/search_service.dart"; import "package:photos/ui/viewer/search/result/magic_result_screen.dart"; import "package:photos/utils/navigation_util.dart"; import "package:shared_preferences/shared_preferences.dart"; class MagicCache { final String title; final List fileUploadedIDs; Map? _fileIdToPositionMap; MagicCache(this.title, this.fileUploadedIDs); // Get map of uploadID to index in fileUploadedIDs Map get fileIdToPositionMap { if (_fileIdToPositionMap == null) { _fileIdToPositionMap = {}; for (int i = 0; i < fileUploadedIDs.length; i++) { _fileIdToPositionMap![fileUploadedIDs[i]] = i; } } return _fileIdToPositionMap!; } factory MagicCache.fromJson(Map json) { return MagicCache( json['title'], List.from(json['fileUploadedIDs']), ); } Map toJson() { return { 'title': title, 'fileUploadedIDs': fileUploadedIDs.toList(), }; } static String encodeListToJson(List magicCaches) { final jsonList = magicCaches.map((cache) => cache.toJson()).toList(); return jsonEncode(jsonList); } static List decodeJsonToList(String jsonString) { final jsonList = jsonDecode(jsonString) as List; return jsonList.map((json) => MagicCache.fromJson(json)).toList(); } } String getLocalizedTitle(BuildContext context, String title) { switch (title) { case 'Identity': return context.l10n.discover_identity; case 'Screenshots': return context.l10n.discover_screenshots; case 'Receipts': return context.l10n.discover_receipts; case 'Notes': return context.l10n.discover_notes; case 'Memes': return context.l10n.discover_memes; case 'Visiting Cards': return context.l10n.discover_visiting_cards; case 'Babies': return context.l10n.discover_babies; case 'Pets': return context.l10n.discover_pets; case 'Selfies': return context.l10n.discover_selfies; case 'Wallpapers': return context.l10n.discover_wallpapers; case 'Food': return context.l10n.discover_food; case 'Celebrations': return context.l10n.discover_celebrations; case 'Sunset': return context.l10n.discover_sunset; case 'Hills': return context.l10n.discover_hills; case 'Greenery': return context.l10n.discover_greenery; default: return title; // If no match, return the original string } } GenericSearchResult? toGenericSearchResult( BuildContext context, Prompt prompt, List enteFilesInMagicCache, Map fileIdToPositionMap, ) { if (enteFilesInMagicCache.isEmpty) { return null; } if (!prompt.recentFirst) { enteFilesInMagicCache.sort((a, b) { return fileIdToPositionMap[a.uploadedFileID!]! .compareTo(fileIdToPositionMap[b.uploadedFileID!]!); }); } final String title = getLocalizedTitle(context, prompt.title); return GenericSearchResult( ResultType.magic, title, enteFilesInMagicCache, params: { "enableGrouping": prompt.recentFirst, "fileIdToPosMap": fileIdToPositionMap, }, onResultTap: (ctx) { routeToPage( ctx, MagicResultScreen( enteFilesInMagicCache, name: title, enableGrouping: prompt.recentFirst, fileIdToPosMap: fileIdToPositionMap, heroTag: GenericSearchResult( ResultType.magic, title, enteFilesInMagicCache, ).heroTag(), ), ); }, ); } class MagicCacheService { static const _lastMagicCacheUpdateTime = "last_magic_cache_update_time"; static const _kMagicPromptsDataUrl = "https://discover.ente.io/v2.json"; /// Delay is for cache update to be done not during app init, during which a /// lot of other things are happening. static const _kCacheUpdateDelay = Duration(seconds: 10); late SharedPreferences _prefs; final Logger _logger = Logger((MagicCacheService).toString()); MagicCacheService._privateConstructor(); Future>? _magicCacheFuture; Future>? _promptFuture; final Set _pendingUpdateReason = {}; bool _isUpdateInProgress = false; static final MagicCacheService instance = MagicCacheService._privateConstructor(); void init(SharedPreferences preferences) { _logger.info("Initializing MagicCacheService"); _prefs = preferences; Future.delayed(_kCacheUpdateDelay, () { _updateCacheIfTheTimeHasCome(); }); Bus.instance.on().listen((event) { _pendingUpdateReason.add("File uploaded"); }); } Future _resetLastMagicCacheUpdateTime() async { await _prefs.setInt( _lastMagicCacheUpdateTime, DateTime.now().millisecondsSinceEpoch, ); } int get lastMagicCacheUpdateTime { return _prefs.getInt(_lastMagicCacheUpdateTime) ?? 0; } bool get enableDiscover => localSettings.isMLIndexingEnabled; Future _updateCacheIfTheTimeHasCome() async { if (!enableDiscover) { return; } final updatedJSONFile = await RemoteAssetsService.instance .getAssetIfUpdated(_kMagicPromptsDataUrl); if (updatedJSONFile != null) { _pendingUpdateReason.add("Prompts data updated"); } else if (lastMagicCacheUpdateTime < DateTime.now() .subtract(const Duration(days: 1)) .millisecondsSinceEpoch) { _pendingUpdateReason.add("Cache is old"); } } Future _getCachePath() async { return (await getApplicationSupportDirectory()).path + "/cache/magic_cache"; } Future updateCache({bool forced = false}) async { if (!enableDiscover) { return; } if (forced) { _pendingUpdateReason.add("Forced update"); } try { if (_pendingUpdateReason.isEmpty || _isUpdateInProgress) { _logger.info( "No update needed as ${_pendingUpdateReason.toList()} and isUpdateInProgress $_isUpdateInProgress", ); return; } _logger.info("updating magic cache ${_pendingUpdateReason.toList()}"); _pendingUpdateReason.clear(); _isUpdateInProgress = true; final EnteWatch? w = kDebugMode ? EnteWatch("magicCacheWatch") : null; w?.start(); final magicPromptsData = await getPrompts(); w?.log("loadedPrompts"); final List magicCaches = await _nonEmptyMagicResults(magicPromptsData); w?.log("resultComputed"); final file = File(await _getCachePath()); if (!file.existsSync()) { file.createSync(recursive: true); } _magicCacheFuture = Future.value(magicCaches); await file .writeAsBytes(MagicCache.encodeListToJson(magicCaches).codeUnits); w?.log("cacheWritten"); await _resetLastMagicCacheUpdateTime(); w?.logAndReset('done'); Bus.instance.fire(MagicCacheUpdatedEvent()); } catch (e, s) { _logger.info("Error updating magic cache", e, s); } finally { _isUpdateInProgress = false; Bus.instance.fire(MagicCacheUpdatedEvent()); } } Future> getPrompts() async { if (_promptFuture != null) { return _promptFuture!; } _promptFuture = _readPromptFromDiskOrNetwork(); return _promptFuture!; } Future> _getMagicCache() async { if (_magicCacheFuture != null) { return _magicCacheFuture!; } _magicCacheFuture = _readResultFromDisk(); return _magicCacheFuture!; } Future> _readPromptFromDiskOrNetwork() async { final String path = await RemoteAssetsService.instance.getAssetPath(_kMagicPromptsDataUrl); return Computer.shared().compute( _loadMagicPrompts, param: { "path": path, }, ); } Future> _readResultFromDisk() async { _logger.info("Reading magic cache result from disk"); final file = File(await _getCachePath()); if (!file.existsSync()) { _logger.info("No magic cache found"); return []; } final jsonString = file.readAsStringSync(); return MagicCache.decodeJsonToList(jsonString); } Future clearMagicCache() async { await File(await _getCachePath()).delete(); } Future> getMagicGenericSearchResult( BuildContext context, ) async { try { final EnteWatch? w = kDebugMode ? EnteWatch("magicGenericSearchResult") : null; w?.start(); final magicCaches = await _getMagicCache(); final List prompts = await getPrompts(); if (magicCaches.isEmpty) { w?.log("No magic cache found"); return []; } else { w?.log("cacheFound"); } final Map> magicIdToFiles = {}; final Map promptMap = {}; final Map> promptFileOrder = {}; for (MagicCache c in magicCaches) { magicIdToFiles[c.title] = []; promptFileOrder[c.title] = c.fileIdToPositionMap; } for (final p in prompts) { promptMap[p.title] = p; } final List genericSearchResults = []; final List files = await SearchService.instance.getAllFiles(); for (EnteFile file in files) { if (!file.isUploaded) continue; for (MagicCache magicCache in magicCaches) { if (magicCache.fileIdToPositionMap .containsKey(file.uploadedFileID!)) { if (file.isVideo && (promptMap[magicCache.title]?.showVideo ?? true) == false) { continue; } magicIdToFiles[magicCache.title]!.add(file); } } } for (final p in prompts) { final genericSearchResult = toGenericSearchResult( context, p, magicIdToFiles[p.title] ?? [], promptFileOrder[p.title] ?? {}, ); if (genericSearchResult != null) { genericSearchResults.add(genericSearchResult); } } w?.logAndReset("done"); return genericSearchResults; } catch (e, s) { _logger.info("Error getting magic generic search result", e, s); return []; } } static Future> _loadMagicPrompts( Map args, ) async { final String path = args["path"] as String; final File file = File(path); final String contents = await file.readAsString(); final Map promptsJson = jsonDecode(contents); final List promptData = promptsJson['prompts']; return promptData .map((jsonItem) => Prompt.fromJson(jsonItem)) .toList(); } ///Returns non-empty magic results from magicPromptsData ///Length is number of prompts, can be less if there are not enough non-empty ///results Future> _nonEmptyMagicResults( List magicPromptsData, ) async { final results = []; for (Prompt prompt in magicPromptsData) { final fileUploadedIDs = await SemanticSearchService.instance.getMatchingFileIDs( prompt.query, prompt.minScore, ); if (fileUploadedIDs.isNotEmpty) { results.add( MagicCache(prompt.title, fileUploadedIDs), ); } } return results; } }