import 'dart:async'; import 'dart:io'; import 'dart:isolate'; import 'dart:math'; import 'package:flutter/services.dart'; import 'package:jni/jni.dart'; import 'package:k3vinb5_aniyomi_bridge/jni_isolate.dart'; import 'package:k3vinb5_aniyomi_bridge/jni_isolate_message.dart'; import 'package:k3vinb5_aniyomi_bridge/models/extension.dart'; import 'package:logger/logger.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import 'package:k3vinb5_aniyomi_bridge/jmodels/jvideo.dart'; import 'package:k3vinb5_aniyomi_bridge/jmodels/jsanime.dart'; import 'package:k3vinb5_aniyomi_bridge/jmodels/jsepisode.dart'; import 'package:k3vinb5_aniyomi_bridge/jmodels/jsmanga.dart'; import 'package:k3vinb5_aniyomi_bridge/jmodels/jschapter.dart'; import 'package:k3vinb5_aniyomi_bridge/jmodels/jpage.dart'; class AniyomiBridge { // static consts static const String _aniyomiBridgeAnimeExtensionsDir = "animeExtensions"; static const String _aniyomiBridgeMangaExtensionsDir = "mangaExtensions"; static const String _aniyomiBridgeDir = "aniyomibridge"; static const String _aniyomiBridgeJarName = "aniyomibridge-core.jar"; static const String _packageAssetsDir = "packages/k3vinb5_aniyomi_bridge/assets"; static const Duration _cacheTtl = Duration(minutes: 20); // static variables static bool _isReady = false; static late final JniIsolate _jniIsolate; static late final ReceivePort _jniReceiverPort; static late final SendPort _jniSenderPort; // instance variables late final String _supportDirectoryPath; final Random _random = Random(); final Logger _logger = Logger( printer: PrettyPrinter(methodCount: 9), level: Level.debug, // output: FileOutput(), filter: ProductionFilter(), ); final Map _pendingRequests = {}; final Map _responsesCache = {}; AniyomiBridge() { _initJvm(); } Future _initJvm() async { if (_isReady) { return; } Directory supportDirectory = await getApplicationSupportDirectory(); _supportDirectoryPath = supportDirectory.path; await _loadJarIfNeeded(supportDirectory); Jni.setDylibDir(dylibDir: _getDylibDir(supportDirectory)); _jniIsolate = JniIsolate(); final readyPort = ReceivePort(); _jniReceiverPort = ReceivePort(); (bool, SendPort) response = await _jniIsolate.initJniIsolate( readyPort, _jniReceiverPort, supportDirectory, _getDylibDir(supportDirectory), _getClassPath(supportDirectory), ); _isReady = response.$1; _jniSenderPort = response.$2; _jniReceiverPort.listen((message) { if (message is JniIsolateError) { final id = message.id; final completer = _pendingRequests.remove(id); if (completer == null) return; completer.completeError(message.error); } else if (message is JniIsolateResponse) { final id = message.id; final completer = _pendingRequests.remove(id); if (completer == null) return; if (message.results == null) { completer.complete(); return; } completer.complete(message.results); } });} bool isReady() { return _isReady; } Future> getAnimeSearchResults(String query, int page, String source) async { Map args = { 'query': query, 'page': page, 'source': source, }; Future>? cachedResponse = _getCachedResponse>>(JniIsolateMessageType.getAnimeSearchResults, args); if (cachedResponse != null) return cachedResponse; Future> response = _sendJniIsolateMessage>( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.getAnimeSearchResults, args: args, ), ); _cacheResponse(response, JniIsolateMessageType.getAnimeSearchResults, args); return response; } Future> getMangaSearchResults(String query, int page, String source) async { Map args = { 'query': query, 'page': page, 'source': source, }; Future>? cachedResponse = _getCachedResponse>>(JniIsolateMessageType.getMangaSearchResults, args); if (cachedResponse != null) return cachedResponse; Future> response = _sendJniIsolateMessage>( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.getMangaSearchResults, args: args, ), ); _cacheResponse(response, JniIsolateMessageType.getMangaSearchResults, args); return response; } Future> getEpisodeList(JSAnime sAnime, String source) async { Map args = { 'sAnime': sAnime, 'source': source, }; Future>? cachedResponse = _getCachedResponse>>(JniIsolateMessageType.getEpisodeList, args); if (cachedResponse != null) return cachedResponse; Future> response = _sendJniIsolateMessage>( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.getEpisodeList, args: args, ), ); _cacheResponse(response, JniIsolateMessageType.getEpisodeList, args); return response; } Future> getChapterList(JSManga sManga, String source) async { Map args = { 'sManga': sManga, 'source': source, }; Future>? cachedResponse = _getCachedResponse>>(JniIsolateMessageType.getChapterList, args); if (cachedResponse != null) return cachedResponse; Future> response = _sendJniIsolateMessage>( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.getChapterList, args: args, ), ); _cacheResponse(response, JniIsolateMessageType.getChapterList, args); return response; } Future> getVideoList(JSEpisode sEpisode, String source) async { Map args = { 'sEpisode': sEpisode, 'source': source, }; Future>? cachedResponse = _getCachedResponse>>(JniIsolateMessageType.getVideoList, args); if (cachedResponse != null) return cachedResponse; Future> response = _sendJniIsolateMessage>( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.getVideoList, args: args, ), ); _cacheResponse(response, JniIsolateMessageType.getVideoList, args); return response; } Future> getPageList(JSChapter sChapter, String source) async { Map args = { 'sChapter': sChapter, 'source': source, }; Future>? cachedResponse = _getCachedResponse>>(JniIsolateMessageType.getPageList, args); if (cachedResponse != null) return cachedResponse; Future> response = _sendJniIsolateMessage>( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.getPageList, args: args, ), ); _cacheResponse(response, JniIsolateMessageType.getPageList, args); return response; } Future loadAnimeExtension(String extensionUrl) async { return _sendJniIsolateMessage( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.loadAnimeExtension, args: { 'extensionUrl': extensionUrl, }, ), ); } Future unloadAnimeExtension(String extensionName, String extensionVersion) async { return _sendJniIsolateMessage( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.unloadAnimeExtension, args: { 'extensionName': extensionName, 'extensionVersion': extensionVersion, }, ), ); } Future loadMangaExtension(String extensionUrl) async { return _sendJniIsolateMessage( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.loadMangaExtension, args: { 'extensionUrl': extensionUrl, }, ), ); } Future unloadMangaExtension(String extensionName, String extensionVersion) async { return _sendJniIsolateMessage( JniIsolateMessage( id: _random.nextInt(1 << 32), type: JniIsolateMessageType.unloadMangaExtension, args: { 'extensionName': extensionName, 'extensionVersion': extensionVersion, }, ), ); } Future> getInstalledAnimeExtensions() async { Directory animeExtDir = Directory( path.join(_supportDirectoryPath, _aniyomiBridgeAnimeExtensionsDir), ); Set extensions = await animeExtDir .list() .where((entityFileSystem) => entityFileSystem is File) .where((file) => path.extension(file.path) == ".jar") .map((jarFile) => path.basename(jarFile.path)) .map( (jarFileName) => Extension( name: jarFileName.split("-")[0], version: jarFileName.split("-")[1], ), ) .toSet(); return extensions; } Future> getInstalledMangaExtensions() async { Directory mangaExtDir = Directory( path.join(_supportDirectoryPath, _aniyomiBridgeMangaExtensionsDir), ); Set extensions = await mangaExtDir .list() .where((entityFileSystem) => entityFileSystem is File) .where((file) => path.extension(file.path) == ".jar") .map((jarFile) => path.basename(jarFile.path)) .map( (jarFileName) => Extension( name: jarFileName.split("-")[0], version: jarFileName.split("-")[1], ), ) .toSet(); return extensions; } // List? getLoadedAnimeExtensions() { // JList? loadedExtensions = _jAniyomiBridge // .getAnimeLoadedExtensions(); // if (loadedExtensions == null) { // return null; // } // return loadedExtensions // .cast() // .map((jStr) => jStr.toDartString()) // .toList(); // } // // List? getLoadedMangaExtensions() { // JList? loadedExtensions = _jAniyomiBridge // .getMangaLoadedExtensions(); // if (loadedExtensions == null) { // return null; // } // return loadedExtensions // .cast() // .map((jStr) => jStr.toDartString()) // .toList(); // } Future _sendJniIsolateMessage(JniIsolateMessage message) { final completer = Completer(); _pendingRequests[message.id] = completer; _jniSenderPort.send(message); return completer.future.timeout( const Duration(seconds: 20), onTimeout: () => throw TimeoutException("JNI Isolate timed out") ); } String _getDylibDir(Directory supportDirectory) { String executablePath = File(Platform.resolvedExecutable).parent.path; if (Platform.isLinux) { return path.join(executablePath, "jre", "customjre", "lib", "server"); } else if (Platform.isMacOS) { return path.join( executablePath, "..", "Resources", "jre", "customjre", "lib", "server", ); } else if (Platform.isWindows) { return path.join( executablePath, "..", "jre", "customjre", "lib", "server", ); } else { throw UnsupportedError("Unsupported platform"); } } List _getClassPath(Directory supportDirectory) { return [ path.join( supportDirectory.absolute.path, _aniyomiBridgeDir, _aniyomiBridgeJarName, ), ]; } Future _loadJarIfNeeded(Directory supportDirectory) async { String aniyomiBridgeCorePath = path.join( supportDirectory.path, _aniyomiBridgeDir, _aniyomiBridgeJarName, ); File aniyomiBridgeCore = File(aniyomiBridgeCorePath); if (!(await aniyomiBridgeCore.exists())) { await _copyAssetToFile( "$_packageAssetsDir/$_aniyomiBridgeJarName", aniyomiBridgeCorePath, ); } } Future _copyAssetToFile(String assetPath, String outPath) async { final byteData = await rootBundle.load(assetPath); final buffer = byteData.buffer.asUint8List(); final file = File(outPath); await file.parent.create(recursive: true); await file.writeAsBytes(buffer, flush: true); return file; } T? _getCachedResponse(JniIsolateMessageType messageType, Map args) { // if (!_isCacheable(messageType)) return null; final cacheKey = _generateCacheKey(messageType, args); if (_responsesCache.containsKey(cacheKey)) { _logger.d("Cached response found for ${messageType.name}"); try { final (expiry, cachedResponse as T) = _responsesCache[cacheKey]!; if (expiry < DateTime.now().millisecondsSinceEpoch) { _logger.d( "Cached response for ${messageType.name} with cache key $cacheKey has expired, making new request", ); _responsesCache.remove(cacheKey); } else { _logger.d("Cached response for ${messageType.name} with cache key $cacheKey is still valid for ${(expiry - DateTime.now().millisecondsSinceEpoch) / 1000}s"); return cachedResponse; } } catch (e, stackTrace) { _logger.e( "Error while retrieving cached response for ${messageType.name}: $e", stackTrace: stackTrace, ); _responsesCache.remove(cacheKey); return null; } } _logger.d("No cached response found for ${messageType.name} with cache key $cacheKey"); return null; } void _cacheResponse(T response, JniIsolateMessageType messageType, Map args) { final cacheKey = _generateCacheKey(messageType, args); _logger.d("Caching response for ${messageType.name} with cache key $cacheKey"); _responsesCache[cacheKey] = ( DateTime.now().millisecondsSinceEpoch + _cacheTtl.inMilliseconds, response, ); } String _generateCacheKey(JniIsolateMessageType messageType, Map args) { // Create a stable string representation of the args // final argsString = jsonEncode(args); final argsString = args.entries .map((entry) => "${entry.key}:${_getStableHashCode(entry.value)}") .join(":").hashCode; return "${messageType.name}_$argsString"; } int _getStableHashCode(dynamic value) { if (value is JSEpisode) { return value.getUrl().toDartString().hashCode; }else if (value is JSAnime) { return value.getUrl().toDartString().hashCode; } else if (value is JSManga) { return value.getUrl().toDartString().hashCode; } else if (value is JSChapter) { return value.getUrl().toDartString().hashCode; } return value.hashCode; } }