From 9465d85109960760109bfb656078cec5c935a7ca Mon Sep 17 00:00:00 2001 From: Daniel Brooks Date: Tue, 25 Nov 2025 08:15:21 -0800 Subject: [PATCH 1/6] feat(cdn/viewDispoal): adding in cdn caching and view disposal checking --- .../nitro/rive/ReferencedAssetLoader.kt | 206 ++++++++++++++- ios/ReferencedAssetLoader.swift | 247 +++++++++++++++++- 2 files changed, 435 insertions(+), 18 deletions(-) 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 339c39ac..f3ad78cb 100644 --- a/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt +++ b/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt @@ -14,16 +14,102 @@ import java.io.File as JavaFile import java.io.IOException import java.net.URI import java.net.URL +import java.security.MessageDigest +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write typealias ReferencedAssetCache = MutableMap class ReferencedAssetLoader { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var isDisposed = false + private val cacheLock = ReentrantReadWriteLock() private fun logError(message: String) { Log.e("ReferencedAssetLoader", message) } + private fun logDebug(message: String) { + Log.d("ReferencedAssetLoader", message) + } + + /** + * Get the cache directory for storing CDN assets + */ + private fun getCacheDir(context: Context): JavaFile { + val cacheDir = context.cacheDir + val riveCacheDir = JavaFile(cacheDir, "rive_assets") + if (!riveCacheDir.exists()) { + riveCacheDir.mkdirs() + } + return riveCacheDir + } + + /** + * Generate a cache key from a URL or UUID + */ + private fun generateCacheKey(urlOrUuid: String): String { + return try { + val md = MessageDigest.getInstance("MD5") + val hashBytes = md.digest(urlOrUuid.toByteArray()) + hashBytes.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + // Fallback to hash code if MD5 is not available + urlOrUuid.hashCode().toString().replace("-", "") + } + } + + /** + * Get cached file path for a URL/UUID + */ + private fun getCachedFilePath(context: Context, urlOrUuid: String): JavaFile { + val cacheKey = generateCacheKey(urlOrUuid) + return JavaFile(getCacheDir(context), cacheKey) + } + + /** + * Check if a cached file exists and is valid + */ + private fun getCachedAsset(context: Context, urlOrUuid: String): ByteArray? { + return cacheLock.read { + val cacheFile = getCachedFilePath(context, urlOrUuid) + if (!cacheFile.exists()) { + logDebug("Cache miss for: $urlOrUuid") + return@read null + } + + try { + val data = cacheFile.readBytes() + if (data.isNotEmpty()) { + logDebug("Cache hit for: $urlOrUuid") + return@read data + } + } catch (e: Exception) { + logDebug("Error reading cache for $urlOrUuid: ${e.message}") + } + + null + } + } + + /** + * Save asset data to cache + */ + private fun saveToCache(context: Context, urlOrUuid: String, data: ByteArray) { + scope.launch(Dispatchers.IO) { + cacheLock.write { + val cacheFile = getCachedFilePath(context, urlOrUuid) + try { + cacheFile.writeBytes(data) + logDebug("Saved to cache: $urlOrUuid (${data.size} bytes)") + } catch (e: Exception) { + logDebug("Error saving cache for $urlOrUuid: ${e.message}") + } + } + } + } + private fun isValidUrl(url: String): Boolean { return try { URL(url) @@ -63,7 +149,14 @@ class ReferencedAssetLoader { } } - private fun downloadUrlAsset(url: String, listener: (ByteArray?) -> Unit) { + private fun downloadUrlAsset(url: String, context: Context, listener: (ByteArray?) -> Unit) { + // Check if disposed before starting download + if (isDisposed) { + logDebug("Loader is disposed, skipping download: $url") + listener(null) + return + } + if (!isValidUrl(url)) { logError("Invalid URL: $url") listener(null) @@ -82,10 +175,51 @@ class ReferencedAssetLoader { if (!file.canRead()) { throw IOException("Permission denied: ${uri.path}") } - file.readBytes() + val fileBytes = file.readBytes() + // Check again before calling listener + if (isDisposed) { + logDebug("Loader disposed before calling listener for file: $url") + withContext(Dispatchers.Main) { + listener(null) + } + return@launch + } + fileBytes } "http", "https" -> { - URL(url).readBytes() + // Check cache first for HTTP/HTTPS URLs + val cachedData = getCachedAsset(context, url) + if (cachedData != null) { + // Check again before calling listener + if (isDisposed) { + logDebug("Loader disposed before calling listener for cached: $url") + withContext(Dispatchers.Main) { + listener(null) + } + return@launch + } + withContext(Dispatchers.Main) { + listener(cachedData) + } + return@launch + } + + // Download from network + val downloadedBytes = URL(url).readBytes() + + // Save to cache + saveToCache(context, url, downloadedBytes) + + // Final check before calling listener + if (isDisposed) { + logDebug("Loader disposed before calling listener: $url") + withContext(Dispatchers.Main) { + listener(null) + } + return@launch + } + + downloadedBytes } else -> { logError("Unsupported URL scheme: ${uri.scheme}") @@ -118,7 +252,7 @@ class ReferencedAssetLoader { val scheme = runCatching { Uri.parse(sourceAssetId).scheme }.getOrNull() if (scheme != null) { - downloadUrlAsset(sourceAssetId, listener) + downloadUrlAsset(sourceAssetId, context, listener) return@launch } @@ -180,6 +314,16 @@ class ReferencedAssetLoader { } private fun processAssetBytes(bytes: ByteArray, asset: FileAsset) { + // Check if disposed before processing + if (isDisposed) { + logDebug("Loader is disposed, skipping asset processing: ${asset.name}") + return + } + + if (bytes.isEmpty()) { + return + } + when (asset) { is ImageAsset -> asset.image = RiveRenderImage.make(bytes) is FontAsset -> asset.font = RiveFont.make(bytes) @@ -188,9 +332,15 @@ class ReferencedAssetLoader { } private fun loadAsset(assetData: ResolvedReferencedAsset, asset: FileAsset, context: Context): Deferred { + // Check if disposed before starting + if (isDisposed) { + logDebug("Loader is disposed, skipping asset load: ${asset.name}") + return CompletableDeferred().apply { complete(Unit) } + } + val deferred = CompletableDeferred() val listener: (ByteArray?) -> Unit = { bytes -> - if (bytes != null) { + if (bytes != null && !isDisposed) { processAssetBytes(bytes, asset) } deferred.complete(Unit) @@ -201,7 +351,7 @@ class ReferencedAssetLoader { loadResourceAsset(assetData.sourceAssetId, context, listener) } assetData.sourceUrl != null -> { - downloadUrlAsset(assetData.sourceUrl, listener) + downloadUrlAsset(assetData.sourceUrl, context, listener) } assetData.sourceAsset != null -> { loadBundledAsset(assetData.sourceAsset, assetData.path, context, listener) @@ -227,8 +377,49 @@ class ReferencedAssetLoader { return object : FileAssetLoader() { override fun loadContents(asset: FileAsset, inBandBytes: ByteArray): Boolean { + // Check if disposed + if (isDisposed) { + logDebug("Loader is disposed, skipping loadContents for: ${asset.name}") + return false + } + + // Check for CDN URL/UUID first (only if both are non-empty) + val cdnUrl = asset.cdnUrl + + if (cdnUrl != null && cdnUrl.isNotEmpty()) { + logDebug("Loading CDN asset from URL: $cdnUrl") + + val cached = getCachedAsset(context, cdnUrl) + if (cached != null) { + // Use cached version + scope.launch(Dispatchers.IO) { + if (!isDisposed) { + processAssetBytes(cached, asset) + } + } + cache[asset.uniqueFilename.substringBeforeLast(".")] = asset + cache[asset.name] = asset + return true + } else { + // Download and cache + cache[asset.uniqueFilename.substringBeforeLast(".")] = asset + cache[asset.name] = asset + + val cdnAssetData = ResolvedReferencedAsset( + sourceUrl = cdnUrl, + sourceAssetId = null, + sourceAsset = null, + path = null + ) + loadAsset(cdnAssetData, asset, context) + return true + } + } + var key = asset.uniqueFilename.substringBeforeLast(".") var assetData = assetsData[key] + cache[key] = asset + cache[asset.name] = asset if (assetData == null) { key = asset.name @@ -239,8 +430,6 @@ class ReferencedAssetLoader { return false } - cache[key] = asset - loadAsset(assetData, asset, context) return true @@ -249,6 +438,7 @@ class ReferencedAssetLoader { } fun dispose() { + isDisposed = true scope.cancel() } } diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index f4c18c4c..f1829e6c 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -1,5 +1,6 @@ import NitroModules import RiveRuntime +import Foundation struct FileAndCache { var file: RiveFile @@ -28,11 +29,96 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError { } final class ReferencedAssetLoader { + private var isDisposed = false + private let cacheQueue = DispatchQueue(label: "com.rive.assetCache", attributes: .concurrent) + private func handleRiveError(error: Error) { // TODO allow user to specify onError callback RCTLogError("\(error)") } + private func logDebug(_ message: String) { + #if DEBUG + print("[ReferencedAssetLoader] \(message)") + #endif + } + + /** + * Get the cache directory for storing CDN assets + */ + private func getCacheDir() -> URL { + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + let riveCacheDir = cacheDir.appendingPathComponent("rive_assets", isDirectory: true) + + // Create directory if it doesn't exist + if !FileManager.default.fileExists(atPath: riveCacheDir.path) { + try? FileManager.default.createDirectory(at: riveCacheDir, withIntermediateDirectories: true, attributes: nil) + } + + return riveCacheDir + } + + /** + * Generate a cache key from a URL or UUID + */ + private func generateCacheKey(_ urlOrUuid: String) -> String { + // Use a simple hash-based approach that doesn't require CommonCrypto + // This creates a deterministic cache key from the URL/UUID + let hash = urlOrUuid.hashValue + // Convert to positive hex string + let hashString = String(format: "%x", abs(hash)) + // Use first 32 characters (MD5-like length) or pad if needed + return String(hashString.prefix(32)).padding(toLength: 32, withPad: "0", startingAt: 0) + } + + /** + * Get cached file path for a URL/UUID + */ + private func getCachedFilePath(_ urlOrUuid: String) -> URL { + let cacheKey = generateCacheKey(urlOrUuid) + return getCacheDir().appendingPathComponent(cacheKey) + } + + /** + * Check if a cached file exists and is valid + */ + private func getCachedAsset(_ urlOrUuid: String) -> Data? { + return cacheQueue.sync { + let cacheFile = getCachedFilePath(urlOrUuid) + guard FileManager.default.fileExists(atPath: cacheFile.path) else { + logDebug("Cache miss for: \(urlOrUuid)") + return nil + } + + do { + let data = try Data(contentsOf: cacheFile) + if data.count > 0 { + logDebug("Cache hit for: \(urlOrUuid)") + return data + } + } catch { + logDebug("Error reading cache for \(urlOrUuid): \(error.localizedDescription)") + } + + return nil + } + } + + /** + * Save asset data to cache + */ + private func saveToCache(_ urlOrUuid: String, data: Data) { + cacheQueue.async(flags: .barrier) { + let cacheFile = self.getCachedFilePath(urlOrUuid) + do { + try data.write(to: cacheFile) + self.logDebug("Saved to cache: \(urlOrUuid) (\(data.count) bytes)") + } catch { + self.logDebug("Error saving cache for \(urlOrUuid): \(error.localizedDescription)") + } + } + } + private func handleInvalidUrlError(url: String) { handleRiveError(error: createIncorrectRiveURL(url)) } @@ -40,14 +126,28 @@ final class ReferencedAssetLoader { private func downloadUrlAsset( url: String, listener: @escaping (Data) -> Void, onError: @escaping () -> Void ) { + // Check if disposed before starting download + guard !isDisposed else { + logDebug("Loader is disposed, skipping download: \(url)") + onError() + return + } + guard isValidUrl(url) else { handleInvalidUrlError(url: url) onError() return } + if let fileUrl = URL(string: url), fileUrl.scheme == "file" { do { let data = try Data(contentsOf: fileUrl) + // Check again before calling listener + guard !isDisposed else { + logDebug("Loader disposed before calling listener for file: \(url)") + onError() + return + } listener(data) } catch { handleInvalidUrlError(url: url) @@ -56,6 +156,18 @@ final class ReferencedAssetLoader { return } + // Check cache first for HTTP/HTTPS URLs + if let cachedData = getCachedAsset(url) { + // Check again before calling listener + guard !isDisposed else { + logDebug("Loader disposed before calling listener for cached: \(url)") + onError() + return + } + listener(cachedData) + return + } + let queue = URLSession.shared guard let requestUrl = URL(string: url) else { handleInvalidUrlError(url: url) @@ -63,12 +175,36 @@ final class ReferencedAssetLoader { return } - let request = URLRequest(url: requestUrl) - let task = queue.dataTask(with: request) { [weak self] data, _, error in - if error != nil { - self?.handleInvalidUrlError(url: url) + var request = URLRequest(url: requestUrl) + request.cachePolicy = .returnCacheDataElseLoad + + let task = queue.dataTask(with: request) { [weak self] data, response, error in + guard let self = self else { + onError() + return + } + + // Check if disposed + guard !self.isDisposed else { + self.logDebug("Loader disposed during download: \(url)") + onError() + return + } + + if let error = error { + self.handleInvalidUrlError(url: url) onError() } else if let data = data { + // Save to cache + self.saveToCache(url, data: data) + + // Final check before calling listener + guard !self.isDisposed else { + self.logDebug("Loader disposed before calling listener: \(url)") + onError() + return + } + listener(data) } else { onError() @@ -81,21 +217,46 @@ final class ReferencedAssetLoader { private func processAssetBytes( _ data: Data, asset: RiveFileAsset, factory: RiveFactory, completion: @escaping () -> Void ) { + // Check if disposed before processing + guard !isDisposed else { + logDebug("Loader is disposed, skipping asset processing: \(asset.name())") + completion() + return + } + if data.isEmpty == true { completion() return } - DispatchQueue.global(qos: .background).async { + + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self, !self.isDisposed else { + DispatchQueue.main.async { + completion() + } + return + } + switch asset { case let imageAsset as RiveImageAsset: let decodedImage = factory.decodeImage(data) - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + // Final check before rendering on main thread + guard let self = self, !self.isDisposed else { + completion() + return + } imageAsset.renderImage(decodedImage) completion() } case let fontAsset as RiveFontAsset: let decodedFont = factory.decodeFont(data) - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + // Final check before rendering on main thread + guard let self = self, !self.isDisposed else { + completion() + return + } fontAsset.font(decodedFont) completion() } @@ -106,7 +267,12 @@ final class ReferencedAssetLoader { } return } - DispatchQueue.main.async { + DispatchQueue.main.async { [weak self] in + // Final check before rendering on main thread + guard let self = self, !self.isDisposed else { + completion() + return + } audioAsset.audio(decodedAudio) completion() } @@ -138,10 +304,21 @@ final class ReferencedAssetLoader { _ sourceUrl: String, asset: RiveFileAsset, factory: RiveFactory, completion: @escaping () -> Void ) { + // Check if disposed before processing + guard !isDisposed else { + logDebug("Loader is disposed, skipping handleSourceUrl: \(sourceUrl)") + completion() + return + } + downloadUrlAsset( url: sourceUrl, listener: { [weak self] data in - self?.processAssetBytes(data, asset: asset, factory: factory, completion: completion) + guard let self = self, !self.isDisposed else { + completion() + return + } + self.processAssetBytes(data, asset: asset, factory: factory, completion: completion) }, onError: completion) } @@ -202,6 +379,13 @@ final class ReferencedAssetLoader { source: ResolvedReferencedAsset, asset: RiveFileAsset, factory: RiveFactory, completion: @escaping () -> Void ) { + // Check if disposed before starting + guard !isDisposed else { + logDebug("Loader is disposed, skipping asset load: \(asset.name())") + completion() + return + } + let sourceAssetId = source.sourceAssetId let sourceUrl = source.sourceUrl let sourceAsset = source.sourceAsset @@ -235,9 +419,48 @@ final class ReferencedAssetLoader { else { return nil } - return { (asset: RiveFileAsset, _: Data, factory: RiveFactory) -> Bool in + + return { [weak self] (asset: RiveFileAsset, data: Data, factory: RiveFactory) -> Bool in + guard let self = self, !self.isDisposed else { + self?.logDebug("Loader is disposed, skipping loadContents for: \(asset.name())") + return false + } + + // Check for CDN URL/UUID first (only if both are non-empty) + let cdnUuid = asset.cdnUuid() + let cdnBaseUrl = asset.cdnBaseUrl() + + if !cdnUuid.isEmpty && !cdnBaseUrl.isEmpty { + let cdnUrl = "\(cdnBaseUrl)/\(cdnUuid)" + self.logDebug("Loading CDN asset from UUID: \(cdnUuid)") + let cached = self.getCachedAsset(cdnUrl) + if let cachedData = cached { + // Use cached version + DispatchQueue.global(qos: .background).async { + guard !self.isDisposed else { + return + } + self.processAssetBytes(cachedData, asset: asset, factory: factory, completion: {}) + } + cache.value[asset.uniqueName()] = asset + cache.value[asset.name()] = asset + factoryOut.value = factory + return true + } else { + // Download and cache + cache.value[asset.uniqueName()] = asset + cache.value[asset.name()] = asset + factoryOut.value = factory + self.handleSourceUrl(cdnUrl, asset: asset, factory: factory, completion: {}) + return true + } + } + let assetByUniqueName = referencedAssets[asset.uniqueName()] guard let assetData = assetByUniqueName ?? referencedAssets[asset.name()] else { + cache.value[asset.uniqueName()] = asset + cache.value[asset.name()] = asset + factoryOut.value = factory return false } @@ -251,4 +474,8 @@ final class ReferencedAssetLoader { return true } } + + func dispose() { + isDisposed = true + } } From e52a1352ce927e929771fa03953b6c3d25e286ae Mon Sep 17 00:00:00 2001 From: Daniel Brooks Date: Sun, 30 Nov 2025 20:59:57 -0800 Subject: [PATCH 2/6] fix(dispose): removing the disposed for ios --- ios/ReferencedAssetLoader.swift | 84 +-------------------------------- 1 file changed, 1 insertion(+), 83 deletions(-) diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index f1829e6c..77cd8879 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -29,7 +29,6 @@ func createAssetFileError(_ assetName: String) -> NitroRiveError { } final class ReferencedAssetLoader { - private var isDisposed = false private let cacheQueue = DispatchQueue(label: "com.rive.assetCache", attributes: .concurrent) private func handleRiveError(error: Error) { @@ -126,12 +125,6 @@ final class ReferencedAssetLoader { private func downloadUrlAsset( url: String, listener: @escaping (Data) -> Void, onError: @escaping () -> Void ) { - // Check if disposed before starting download - guard !isDisposed else { - logDebug("Loader is disposed, skipping download: \(url)") - onError() - return - } guard isValidUrl(url) else { handleInvalidUrlError(url: url) @@ -142,12 +135,6 @@ final class ReferencedAssetLoader { if let fileUrl = URL(string: url), fileUrl.scheme == "file" { do { let data = try Data(contentsOf: fileUrl) - // Check again before calling listener - guard !isDisposed else { - logDebug("Loader disposed before calling listener for file: \(url)") - onError() - return - } listener(data) } catch { handleInvalidUrlError(url: url) @@ -158,12 +145,6 @@ final class ReferencedAssetLoader { // Check cache first for HTTP/HTTPS URLs if let cachedData = getCachedAsset(url) { - // Check again before calling listener - guard !isDisposed else { - logDebug("Loader disposed before calling listener for cached: \(url)") - onError() - return - } listener(cachedData) return } @@ -184,13 +165,6 @@ final class ReferencedAssetLoader { return } - // Check if disposed - guard !self.isDisposed else { - self.logDebug("Loader disposed during download: \(url)") - onError() - return - } - if let error = error { self.handleInvalidUrlError(url: url) onError() @@ -198,13 +172,6 @@ final class ReferencedAssetLoader { // Save to cache self.saveToCache(url, data: data) - // Final check before calling listener - guard !self.isDisposed else { - self.logDebug("Loader disposed before calling listener: \(url)") - onError() - return - } - listener(data) } else { onError() @@ -217,12 +184,6 @@ final class ReferencedAssetLoader { private func processAssetBytes( _ data: Data, asset: RiveFileAsset, factory: RiveFactory, completion: @escaping () -> Void ) { - // Check if disposed before processing - guard !isDisposed else { - logDebug("Loader is disposed, skipping asset processing: \(asset.name())") - completion() - return - } if data.isEmpty == true { completion() @@ -230,7 +191,7 @@ final class ReferencedAssetLoader { } DispatchQueue.global(qos: .background).async { [weak self] in - guard let self = self, !self.isDisposed else { + guard let self = self else { DispatchQueue.main.async { completion() } @@ -241,22 +202,12 @@ final class ReferencedAssetLoader { case let imageAsset as RiveImageAsset: let decodedImage = factory.decodeImage(data) DispatchQueue.main.async { [weak self] in - // Final check before rendering on main thread - guard let self = self, !self.isDisposed else { - completion() - return - } imageAsset.renderImage(decodedImage) completion() } case let fontAsset as RiveFontAsset: let decodedFont = factory.decodeFont(data) DispatchQueue.main.async { [weak self] in - // Final check before rendering on main thread - guard let self = self, !self.isDisposed else { - completion() - return - } fontAsset.font(decodedFont) completion() } @@ -268,11 +219,6 @@ final class ReferencedAssetLoader { return } DispatchQueue.main.async { [weak self] in - // Final check before rendering on main thread - guard let self = self, !self.isDisposed else { - completion() - return - } audioAsset.audio(decodedAudio) completion() } @@ -304,20 +250,10 @@ final class ReferencedAssetLoader { _ sourceUrl: String, asset: RiveFileAsset, factory: RiveFactory, completion: @escaping () -> Void ) { - // Check if disposed before processing - guard !isDisposed else { - logDebug("Loader is disposed, skipping handleSourceUrl: \(sourceUrl)") - completion() - return - } downloadUrlAsset( url: sourceUrl, listener: { [weak self] data in - guard let self = self, !self.isDisposed else { - completion() - return - } self.processAssetBytes(data, asset: asset, factory: factory, completion: completion) }, onError: completion) } @@ -379,12 +315,6 @@ final class ReferencedAssetLoader { source: ResolvedReferencedAsset, asset: RiveFileAsset, factory: RiveFactory, completion: @escaping () -> Void ) { - // Check if disposed before starting - guard !isDisposed else { - logDebug("Loader is disposed, skipping asset load: \(asset.name())") - completion() - return - } let sourceAssetId = source.sourceAssetId let sourceUrl = source.sourceUrl @@ -421,11 +351,6 @@ final class ReferencedAssetLoader { } return { [weak self] (asset: RiveFileAsset, data: Data, factory: RiveFactory) -> Bool in - guard let self = self, !self.isDisposed else { - self?.logDebug("Loader is disposed, skipping loadContents for: \(asset.name())") - return false - } - // Check for CDN URL/UUID first (only if both are non-empty) let cdnUuid = asset.cdnUuid() let cdnBaseUrl = asset.cdnBaseUrl() @@ -437,9 +362,6 @@ final class ReferencedAssetLoader { if let cachedData = cached { // Use cached version DispatchQueue.global(qos: .background).async { - guard !self.isDisposed else { - return - } self.processAssetBytes(cachedData, asset: asset, factory: factory, completion: {}) } cache.value[asset.uniqueName()] = asset @@ -474,8 +396,4 @@ final class ReferencedAssetLoader { return true } } - - func dispose() { - isDisposed = true - } } From 1320a60445cef6af86c3227f7c47f6445b539453 Mon Sep 17 00:00:00 2001 From: Daniel Brooks Date: Sun, 30 Nov 2025 21:01:38 -0800 Subject: [PATCH 3/6] fix(android): removing android disposal --- .../nitro/rive/ReferencedAssetLoader.kt | 66 ++----------------- 1 file changed, 6 insertions(+), 60 deletions(-) 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 f3ad78cb..12ac69fd 100644 --- a/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt +++ b/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt @@ -23,7 +23,6 @@ typealias ReferencedAssetCache = MutableMap class ReferencedAssetLoader { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private var isDisposed = false private val cacheLock = ReentrantReadWriteLock() private fun logError(message: String) { @@ -150,13 +149,6 @@ class ReferencedAssetLoader { } private fun downloadUrlAsset(url: String, context: Context, listener: (ByteArray?) -> Unit) { - // Check if disposed before starting download - if (isDisposed) { - logDebug("Loader is disposed, skipping download: $url") - listener(null) - return - } - if (!isValidUrl(url)) { logError("Invalid URL: $url") listener(null) @@ -176,28 +168,12 @@ class ReferencedAssetLoader { throw IOException("Permission denied: ${uri.path}") } val fileBytes = file.readBytes() - // Check again before calling listener - if (isDisposed) { - logDebug("Loader disposed before calling listener for file: $url") - withContext(Dispatchers.Main) { - listener(null) - } - return@launch - } fileBytes } "http", "https" -> { // Check cache first for HTTP/HTTPS URLs val cachedData = getCachedAsset(context, url) if (cachedData != null) { - // Check again before calling listener - if (isDisposed) { - logDebug("Loader disposed before calling listener for cached: $url") - withContext(Dispatchers.Main) { - listener(null) - } - return@launch - } withContext(Dispatchers.Main) { listener(cachedData) } @@ -206,19 +182,10 @@ class ReferencedAssetLoader { // Download from network val downloadedBytes = URL(url).readBytes() - + // Save to cache saveToCache(context, url, downloadedBytes) - - // Final check before calling listener - if (isDisposed) { - logDebug("Loader disposed before calling listener: $url") - withContext(Dispatchers.Main) { - listener(null) - } - return@launch - } - + downloadedBytes } else -> { @@ -314,12 +281,6 @@ class ReferencedAssetLoader { } private fun processAssetBytes(bytes: ByteArray, asset: FileAsset) { - // Check if disposed before processing - if (isDisposed) { - logDebug("Loader is disposed, skipping asset processing: ${asset.name}") - return - } - if (bytes.isEmpty()) { return } @@ -332,15 +293,9 @@ class ReferencedAssetLoader { } private fun loadAsset(assetData: ResolvedReferencedAsset, asset: FileAsset, context: Context): Deferred { - // Check if disposed before starting - if (isDisposed) { - logDebug("Loader is disposed, skipping asset load: ${asset.name}") - return CompletableDeferred().apply { complete(Unit) } - } - val deferred = CompletableDeferred() val listener: (ByteArray?) -> Unit = { bytes -> - if (bytes != null && !isDisposed) { + if (bytes != null) { processAssetBytes(bytes, asset) } deferred.complete(Unit) @@ -377,25 +332,17 @@ class ReferencedAssetLoader { return object : FileAssetLoader() { override fun loadContents(asset: FileAsset, inBandBytes: ByteArray): Boolean { - // Check if disposed - if (isDisposed) { - logDebug("Loader is disposed, skipping loadContents for: ${asset.name}") - return false - } - // Check for CDN URL/UUID first (only if both are non-empty) val cdnUrl = asset.cdnUrl if (cdnUrl != null && cdnUrl.isNotEmpty()) { logDebug("Loading CDN asset from URL: $cdnUrl") - + val cached = getCachedAsset(context, cdnUrl) if (cached != null) { // Use cached version scope.launch(Dispatchers.IO) { - if (!isDisposed) { - processAssetBytes(cached, asset) - } + processAssetBytes(cached, asset) } cache[asset.uniqueFilename.substringBeforeLast(".")] = asset cache[asset.name] = asset @@ -404,7 +351,7 @@ class ReferencedAssetLoader { // Download and cache cache[asset.uniqueFilename.substringBeforeLast(".")] = asset cache[asset.name] = asset - + val cdnAssetData = ResolvedReferencedAsset( sourceUrl = cdnUrl, sourceAssetId = null, @@ -438,7 +385,6 @@ class ReferencedAssetLoader { } fun dispose() { - isDisposed = true scope.cancel() } } From c513f3dc844b9d64fafcbfc847728d9f20ac3e7f Mon Sep 17 00:00:00 2001 From: Daniel Brooks Date: Mon, 8 Dec 2025 09:59:30 -0800 Subject: [PATCH 4/6] feat(cache): updating to a basic url cache --- PR_DESCRIPTION.md | 114 ++++++++++++++++++ .../com/margelo/nitro/rive/HybridRiveFile.kt | 3 +- .../rive/HybridViewModelImageProperty.kt | 4 +- .../nitro/rive/ReferencedAssetLoader.kt | 22 +++- .../com/margelo/nitro/rive/URLAssetCache.kt | 65 ++++++++++ ...otlin-compiler-12952147549113407283.salive | 0 ios/ReferencedAssetLoader.swift | 39 ++++-- ios/URLAssetCache.swift | 71 +++++++++++ .../c++/JHybridViewModelImagePropertySpec.cpp | 1 + 9 files changed, 305 insertions(+), 14 deletions(-) create mode 100644 PR_DESCRIPTION.md create mode 100644 android/src/main/java/com/margelo/nitro/rive/URLAssetCache.kt create mode 100644 example/android/.kotlin/sessions/kotlin-compiler-12952147549113407283.salive create mode 100644 ios/URLAssetCache.swift diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 00000000..77065e87 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,114 @@ +# Description +This PR adds URL-based asset caching to the `ReferencedAssetLoader` on both Android and iOS platforms. The implementation introduces a dedicated `URLAssetCache` module that caches downloaded HTTP/HTTPS assets to disk, improving performance by avoiding redundant network requests for the same assets. + +## Linear Ticket / References + + + +## Changes Made + +### URL Asset Caching +- **New `URLAssetCache` Module**: Created a dedicated caching system for URL-based assets on both platforms + - **Android** (`URLAssetCache.kt`): + - Uses SHA-256 hashing for cache key generation + - Cache directory: `cacheDir/rive_url_assets/` + - Thread-safe operations with error handling + - Provides `getCachedData()`, `saveToCache()`, and `clearCache()` methods + + - **iOS** (`URLAssetCache.swift`): + - Uses CryptoKit's SHA256 for cache key generation + - Cache directory: `cachesDirectory/rive_url_assets/` + - Thread-safe operations with silent error handling + - Provides static methods for cache operations + +### ReferencedAssetLoader Updates +- **Cache Integration**: Both Android and iOS loaders now check the cache before downloading HTTP/HTTPS assets + - Cache lookup occurs before network requests + - Downloaded assets are automatically saved to cache + - Non-URL assets (bundled, resources) bypass the cache and use direct loading + - Cache operations are transparent to the asset loading flow + +- **Asset Cache Management in `createCustomLoader`**: + - Assets are immediately saved to the cache map when encountered (by both `uniqueName` and `name` keys) + - **Enables Dynamic Asset Updates**: This allows users to pass in new `referencedAssets` later in the file lifecycle. Since assets are cached immediately, new referenced assets can be provided and will properly associate with existing file assets throughout the file's lifecycle. + +### Additional Changes +- **HybridViewModelImageProperty Fix**: Fixed listener callback to properly handle value changes by mapping to `Unit` instead of empty map + +## Testing + +1. **URL Caching Test**: + - Load a Rive file with HTTP/HTTPS referenced assets + - Verify assets are downloaded and cached on first load + - Reload the same file and verify assets are loaded from cache (faster load time, no network request) + - Verify cache directory exists at `cacheDir/rive_url_assets/` (Android) or `cachesDirectory/rive_url_assets/` (iOS) + +2. **Cache Persistence Test**: + - Load assets, close the app + - Reopen the app and load the same file + - Verify assets are still loaded from cache (persistent across app restarts) + +3. **Non-URL Assets Test**: + - Load a Rive file with bundled or resource assets + - Verify these assets bypass the cache and load directly (no cache directory created for them) + +4. **Dynamic Asset Updates Test**: + - Load a Rive file with initial referenced assets + - Update the referenced assets later in the file lifecycle + - Verify new assets are properly associated and loaded + +5. **Cross-platform Verification**: + - Test on both Android and iOS devices + - Verify consistent caching behavior across platforms + +## Screenshots (if applicable) + + +## Additional Information + +### Technical Details +- **Cache Key Generation**: + - **Android**: SHA-256 hash of the URL (64-character hex string) + - **iOS**: SHA256 hash using CryptoKit (64-character hex string) + - Both approaches ensure deterministic, filesystem-safe cache keys + +- **Cache Storage**: + - Files are stored with hashed filenames to avoid filesystem issues with special characters in URLs + - Cache directory is created automatically if it doesn't exist + - Cache persists across app restarts (stored in app's cache directory) + +- **Cache Strategy**: + - Cache-first approach: checks cache before network requests + - Write-through: downloaded assets are immediately cached + - Best-effort: cache failures don't block asset loading + - Only HTTP/HTTPS URLs are cached; other asset sources (bundled, resources) bypass cache + +- **Asset Cache Management**: The `createCustomLoader` function now immediately caches all assets (both by `uniqueFilename`/`uniqueName` and `name`) when they are first encountered, regardless of whether they've been loaded yet. This ensures: + - The cache map is always up-to-date + - Assets can be updated/reloaded with new referenced asset data + - New referenced assets can be provided and will properly associate with existing file assets + +### Performance Impact +- **Positive**: Eliminates redundant network requests for cached assets +- **Positive**: Faster asset loading for previously downloaded assets +- **Positive**: Reduced bandwidth usage +- **Neutral**: Minimal disk space usage (cached files stored in app cache directory, can be cleared by system) +- **Neutral**: Minimal overhead for cache lookups (synchronous file I/O, but only for URL assets) + +### Breaking Changes +None - this is a backward-compatible enhancement. Existing functionality remains unchanged, with caching added as an optimization layer. + +## Developer Checklist + +- [x] My code adheres to the coding and style guidelines of the project. +- [x] I have performed a self-review and testing of my own code. +- [x] I have commented my code and/or added documentation, particularly in hard-to-understand areas. +- [x] I confirm that I have not introduced any secrets directly in this code +- [x] I confirm that I have done my best to avoid security issues (SQL Injection, using trusted and up-to-date components and other [OWASP vulnerabilities](https://owasp.org/Top10/)) + +## Reviewer Checklist + +- [ ] I understand the intent of this code. +- [ ] I have reviewed the code and approve of the approaches taken. +- [ ] I confirm that no secrets have been introduced directly in this code. +- [ ] I confirm that I have done my best to review for security issues (SQL Injection, using trusted and up-to-date components and other [OWASP vulnerabilities](https://owasp.org/Top10/)) diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt b/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt index d03d07e7..da946ad3 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridRiveFile.kt @@ -69,13 +69,12 @@ class HybridRiveFile : HybridRiveFileSpec() { val assetsData = referencedAssets.data ?: return val cache = referencedAssetCache ?: return val loader = assetLoader ?: return - val context = NitroModules.applicationContext ?: return val loadJobs = mutableListOf>() for ((key, assetData) in assetsData) { val asset = cache[key] ?: continue - loadJobs.add(loader.updateAsset(assetData, asset, context)) + loadJobs.add(loader.updateAsset(assetData, asset)) } if (loadJobs.isNotEmpty()) { diff --git a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt index 708ef977..bc35f978 100644 --- a/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt +++ b/android/src/main/java/com/margelo/nitro/rive/HybridViewModelImageProperty.kt @@ -15,7 +15,7 @@ class HybridViewModelImageProperty(private val viewModelImage: ViewModelImagePro } override fun addListener(onChanged: () -> Unit) { - listeners.add(onChanged) - ensureValueListenerJob(viewModelImage.valueFlow.map { }) + listeners.add { _ -> onChanged() } + ensureValueListenerJob(viewModelImage.valueFlow.map { Unit }) } } 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..ce4a868c 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,7 @@ class ReferencedAssetLoader { return object : FileAssetLoader() { override fun loadContents(asset: FileAsset, inBandBytes: ByteArray): Boolean { var key = asset.uniqueFilename.substringBeforeLast(".") + cache[key] = asset var assetData = assetsData[key] if (assetData == null) { @@ -93,8 +111,6 @@ class ReferencedAssetLoader { 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/example/android/.kotlin/sessions/kotlin-compiler-12952147549113407283.salive b/example/android/.kotlin/sessions/kotlin-compiler-12952147549113407283.salive new file mode 100644 index 00000000..e69de29b diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index 052805a2..01a5888c 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,28 @@ 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] + } + + 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 + } + } +} diff --git a/nitrogen/generated/android/c++/JHybridViewModelImagePropertySpec.cpp b/nitrogen/generated/android/c++/JHybridViewModelImagePropertySpec.cpp index 2dc9871a..4cb7fcfd 100644 --- a/nitrogen/generated/android/c++/JHybridViewModelImagePropertySpec.cpp +++ b/nitrogen/generated/android/c++/JHybridViewModelImagePropertySpec.cpp @@ -16,6 +16,7 @@ namespace margelo::nitro::rive { class HybridRiveImageSpec; } #include "JHybridRiveImageSpec.hpp" #include #include "JFunc_void.hpp" +#include namespace margelo::nitro::rive { From 3b94ed6263ccc805a55c931d811ca0d0973f0360 Mon Sep 17 00:00:00 2001 From: Daniel Brooks Date: Mon, 8 Dec 2025 20:28:06 -0800 Subject: [PATCH 5/6] fix(android): updating android and ios cdn rive --- .../java/com/margelo/nitro/rive/ReferencedAssetLoader.kt | 5 +++++ ios/ReferencedAssetLoader.swift | 4 ++++ 2 files changed, 9 insertions(+) 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 ce4a868c..ccb48218 100644 --- a/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt +++ b/android/src/main/java/com/margelo/nitro/rive/ReferencedAssetLoader.kt @@ -100,6 +100,7 @@ class ReferencedAssetLoader { 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) { @@ -107,6 +108,10 @@ 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 } diff --git a/ios/ReferencedAssetLoader.swift b/ios/ReferencedAssetLoader.swift index 01a5888c..ea1668ae 100644 --- a/ios/ReferencedAssetLoader.swift +++ b/ios/ReferencedAssetLoader.swift @@ -154,6 +154,10 @@ final class ReferencedAssetLoader { 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 } From b9d9c8efd194b2f3fe6d22b686df9ebb64271e76 Mon Sep 17 00:00:00 2001 From: Daniel Brooks Date: Mon, 8 Dec 2025 20:35:42 -0800 Subject: [PATCH 6/6] fix(docs): removing pr description --- PR_DESCRIPTION.md | 114 ---------------------------------------------- 1 file changed, 114 deletions(-) delete mode 100644 PR_DESCRIPTION.md diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md deleted file mode 100644 index 77065e87..00000000 --- a/PR_DESCRIPTION.md +++ /dev/null @@ -1,114 +0,0 @@ -# Description -This PR adds URL-based asset caching to the `ReferencedAssetLoader` on both Android and iOS platforms. The implementation introduces a dedicated `URLAssetCache` module that caches downloaded HTTP/HTTPS assets to disk, improving performance by avoiding redundant network requests for the same assets. - -## Linear Ticket / References - - - -## Changes Made - -### URL Asset Caching -- **New `URLAssetCache` Module**: Created a dedicated caching system for URL-based assets on both platforms - - **Android** (`URLAssetCache.kt`): - - Uses SHA-256 hashing for cache key generation - - Cache directory: `cacheDir/rive_url_assets/` - - Thread-safe operations with error handling - - Provides `getCachedData()`, `saveToCache()`, and `clearCache()` methods - - - **iOS** (`URLAssetCache.swift`): - - Uses CryptoKit's SHA256 for cache key generation - - Cache directory: `cachesDirectory/rive_url_assets/` - - Thread-safe operations with silent error handling - - Provides static methods for cache operations - -### ReferencedAssetLoader Updates -- **Cache Integration**: Both Android and iOS loaders now check the cache before downloading HTTP/HTTPS assets - - Cache lookup occurs before network requests - - Downloaded assets are automatically saved to cache - - Non-URL assets (bundled, resources) bypass the cache and use direct loading - - Cache operations are transparent to the asset loading flow - -- **Asset Cache Management in `createCustomLoader`**: - - Assets are immediately saved to the cache map when encountered (by both `uniqueName` and `name` keys) - - **Enables Dynamic Asset Updates**: This allows users to pass in new `referencedAssets` later in the file lifecycle. Since assets are cached immediately, new referenced assets can be provided and will properly associate with existing file assets throughout the file's lifecycle. - -### Additional Changes -- **HybridViewModelImageProperty Fix**: Fixed listener callback to properly handle value changes by mapping to `Unit` instead of empty map - -## Testing - -1. **URL Caching Test**: - - Load a Rive file with HTTP/HTTPS referenced assets - - Verify assets are downloaded and cached on first load - - Reload the same file and verify assets are loaded from cache (faster load time, no network request) - - Verify cache directory exists at `cacheDir/rive_url_assets/` (Android) or `cachesDirectory/rive_url_assets/` (iOS) - -2. **Cache Persistence Test**: - - Load assets, close the app - - Reopen the app and load the same file - - Verify assets are still loaded from cache (persistent across app restarts) - -3. **Non-URL Assets Test**: - - Load a Rive file with bundled or resource assets - - Verify these assets bypass the cache and load directly (no cache directory created for them) - -4. **Dynamic Asset Updates Test**: - - Load a Rive file with initial referenced assets - - Update the referenced assets later in the file lifecycle - - Verify new assets are properly associated and loaded - -5. **Cross-platform Verification**: - - Test on both Android and iOS devices - - Verify consistent caching behavior across platforms - -## Screenshots (if applicable) - - -## Additional Information - -### Technical Details -- **Cache Key Generation**: - - **Android**: SHA-256 hash of the URL (64-character hex string) - - **iOS**: SHA256 hash using CryptoKit (64-character hex string) - - Both approaches ensure deterministic, filesystem-safe cache keys - -- **Cache Storage**: - - Files are stored with hashed filenames to avoid filesystem issues with special characters in URLs - - Cache directory is created automatically if it doesn't exist - - Cache persists across app restarts (stored in app's cache directory) - -- **Cache Strategy**: - - Cache-first approach: checks cache before network requests - - Write-through: downloaded assets are immediately cached - - Best-effort: cache failures don't block asset loading - - Only HTTP/HTTPS URLs are cached; other asset sources (bundled, resources) bypass cache - -- **Asset Cache Management**: The `createCustomLoader` function now immediately caches all assets (both by `uniqueFilename`/`uniqueName` and `name`) when they are first encountered, regardless of whether they've been loaded yet. This ensures: - - The cache map is always up-to-date - - Assets can be updated/reloaded with new referenced asset data - - New referenced assets can be provided and will properly associate with existing file assets - -### Performance Impact -- **Positive**: Eliminates redundant network requests for cached assets -- **Positive**: Faster asset loading for previously downloaded assets -- **Positive**: Reduced bandwidth usage -- **Neutral**: Minimal disk space usage (cached files stored in app cache directory, can be cleared by system) -- **Neutral**: Minimal overhead for cache lookups (synchronous file I/O, but only for URL assets) - -### Breaking Changes -None - this is a backward-compatible enhancement. Existing functionality remains unchanged, with caching added as an optimization layer. - -## Developer Checklist - -- [x] My code adheres to the coding and style guidelines of the project. -- [x] I have performed a self-review and testing of my own code. -- [x] I have commented my code and/or added documentation, particularly in hard-to-understand areas. -- [x] I confirm that I have not introduced any secrets directly in this code -- [x] I confirm that I have done my best to avoid security issues (SQL Injection, using trusted and up-to-date components and other [OWASP vulnerabilities](https://owasp.org/Top10/)) - -## Reviewer Checklist - -- [ ] I understand the intent of this code. -- [ ] I have reviewed the code and approve of the approaches taken. -- [ ] I confirm that no secrets have been introduced directly in this code. -- [ ] I confirm that I have done my best to review for security issues (SQL Injection, using trusted and up-to-date components and other [OWASP vulnerabilities](https://owasp.org/Top10/))