diff --git a/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt b/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt index a6eca014..ccb48218 100644 --- a/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt +++ b/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt @@ -52,7 +52,24 @@ class ReferencedAssetLoader { scope.launch { try { - val bytes = dataSource.createLoader().load(dataSource) + val bytes = when (dataSource) { + is DataSource.Http -> { + // Check cache first for URL assets + val cachedData = URLAssetCache.getCachedData(dataSource.url) + if (cachedData != null) { + cachedData + } else { + // Download and cache + val downloadedData = dataSource.createLoader().load(dataSource) + URLAssetCache.saveToCache(dataSource.url, downloadedData) + downloadedData + } + } + else -> { + // For non-URL assets, use the loader directly + dataSource.createLoader().load(dataSource) + } + } withContext(Dispatchers.Main) { processAssetBytes(bytes, asset) deferred.complete(Unit) @@ -82,6 +99,8 @@ class ReferencedAssetLoader { return object : FileAssetLoader() { override fun loadContents(asset: FileAsset, inBandBytes: ByteArray): Boolean { var key = asset.uniqueFilename.substringBeforeLast(".") + cache[key] = asset + cache[asset.name] = asset var assetData = assetsData[key] if (assetData == null) { @@ -89,12 +108,14 @@ class ReferencedAssetLoader { assetData = assetsData[asset.name] } + if (assetData == null && asset.cdnUrl != null) { + assetData = ResolvedReferencedAsset(sourceUrl = asset.cdnUrl, sourceAsset = null, image = null, sourceAssetId = null, path = null) + } + if (assetData == null) { return false } - cache[key] = asset - loadAsset(assetData, asset) return true diff --git a/android/src/main/java/com/margelo/nitro/rive/URLAssetCache.kt b/android/src/main/java/com/margelo/nitro/rive/URLAssetCache.kt new file mode 100644 index 00000000..494f41d2 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/rive/URLAssetCache.kt @@ -0,0 +1,65 @@ +package com.margelo.nitro.rive + +import android.content.Context +import android.util.Log +import com.margelo.nitro.NitroModules +import java.io.File +import java.security.MessageDigest + +object URLAssetCache { + private const val CACHE_DIR_NAME = "rive_url_assets" + private const val TAG = "URLAssetCache" + + private fun getCacheDir(): File? { + val context = NitroModules.applicationContext ?: return null + val cacheDir = File(context.cacheDir, CACHE_DIR_NAME) + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + return cacheDir + } + + private fun urlToCacheKey(url: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(url.toByteArray()) + return hashBytes.joinToString("") { "%02x".format(it) } + } + + private fun getCacheFile(url: String): File? { + val cacheDir = getCacheDir() ?: return null + val cacheKey = urlToCacheKey(url) + return File(cacheDir, cacheKey) + } + + fun getCachedData(url: String): ByteArray? { + return try { + val cacheFile = getCacheFile(url) ?: return null + if (cacheFile.exists() && cacheFile.length() > 0) { + cacheFile.readBytes() + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, "Failed to read from cache: ${e.message}") + null + } + } + + fun saveToCache(url: String, data: ByteArray) { + try { + val cacheFile = getCacheFile(url) ?: return + cacheFile.writeBytes(data) + } catch (e: Exception) { + Log.e(TAG, "Failed to save to cache: ${e.message}") + } + } + + fun clearCache() { + try { + val cacheDir = getCacheDir() ?: return + cacheDir.listFiles()?.forEach { it.delete() } + } catch (e: Exception) { + Log.e(TAG, "Failed to clear cache: ${e.message}") + } + } +} diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index 052805a2..ea1668ae 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -93,7 +93,23 @@ final class ReferencedAssetLoader { Task { do { - let data = try await dataSource.createLoader().load(from: dataSource) + let data: Data + + // Check cache first for URL assets + if case .http(let url) = dataSource { + if let cachedData = URLAssetCache.getCachedData(for: url.absoluteString) { + data = cachedData + } else { + // Download and cache + let downloadedData = try await dataSource.createLoader().load(from: dataSource) + URLAssetCache.saveToCache(downloadedData, for: url.absoluteString) + data = downloadedData + } + } else { + // For non-URL assets, use the loader directly + data = try await dataSource.createLoader().load(from: dataSource) + } + await MainActor.run { self.processAssetBytes(data, asset: asset, factory: factory, completion: completion) } @@ -120,19 +136,32 @@ final class ReferencedAssetLoader { ) -> LoadAsset? { - guard let referencedAssets = referencedAssets, let referencedAssets = referencedAssets.data + guard let referencedAssets = referencedAssets, let assetsData = referencedAssets.data else { return nil } return { (asset: RiveFileAsset, _: Data, factory: RiveFactory) -> Bool in - let assetByUniqueName = referencedAssets[asset.uniqueName()] - guard let assetData = assetByUniqueName ?? referencedAssets[asset.name()] else { - return false - } - cache.value[asset.uniqueName()] = asset + cache.value[asset.name()] = asset factoryOut.value = factory + // Look up asset data by unique name or name + var key = (asset.uniqueName() as NSString).deletingPathExtension + var assetData = assetsData[key] + + if assetData == nil { + key = asset.name() + assetData = assetsData[key] + } + + if assetData == nil && !asset.cdnUuid().isEmpty { + assetData = ResolvedReferencedAsset(sourceUrl: "\(asset.cdnBaseUrl())/\(asset.cdnUuid())", sourceAsset: nil, sourceAssetId: nil, path: nil, image: nil) + } + + guard let assetData = assetData else { + return false + } + self.loadAssetInternal( source: assetData, asset: asset, factory: factory, completion: { diff --git a/ios/URLAssetCache.swift b/ios/URLAssetCache.swift new file mode 100644 index 00000000..e12e8ce5 --- /dev/null +++ b/ios/URLAssetCache.swift @@ -0,0 +1,71 @@ +import Foundation +import CryptoKit + +enum URLAssetCache { + private static let cacheDirName = "rive_url_assets" + + private static func getCacheDirectory() -> URL? { + guard let cacheDir = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first else { + return nil + } + let riveCacheDir = cacheDir.appendingPathComponent(cacheDirName) + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory( + at: riveCacheDir, + withIntermediateDirectories: true, + attributes: nil + ) + + return riveCacheDir + } + + private static func urlToCacheKey(_ url: String) -> String { + let data = Data(url.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } + + private static func getCacheFileURL(for url: String) -> URL? { + guard let cacheDir = getCacheDirectory() else { return nil } + let cacheKey = urlToCacheKey(url) + return cacheDir.appendingPathComponent(cacheKey) + } + + static func getCachedData(for url: String) -> Data? { + guard let cacheFileURL = getCacheFileURL(for: url) else { return nil } + + do { + let data = try Data(contentsOf: cacheFileURL) + return data.isEmpty ? nil : data + } catch { + return nil + } + } + + static func saveToCache(_ data: Data, for url: String) { + guard let cacheFileURL = getCacheFileURL(for: url) else { return } + + do { + try data.write(to: cacheFileURL) + } catch { + // Silently fail - caching is best effort + } + } + + static func clearCache() { + guard let cacheDir = getCacheDirectory() else { return } + + do { + let files = try FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) + for file in files { + try? FileManager.default.removeItem(at: file) + } + } catch { + // Silently fail + } + } +}