diff --git a/.changeset/orange-ways-invite.md b/.changeset/orange-ways-invite.md
new file mode 100644
index 00000000..f8d62383
--- /dev/null
+++ b/.changeset/orange-ways-invite.md
@@ -0,0 +1,7 @@
+---
+'@use-voltra/android': minor
+'voltra': minor
+'@use-voltra/ios': minor
+---
+
+Add SVG support to image preloading on iOS and Android.
diff --git a/example/screens/android/AndroidImagePreloadingScreen.tsx b/example/screens/android/AndroidImagePreloadingScreen.tsx
index ec7152fc..96d76a10 100644
--- a/example/screens/android/AndroidImagePreloadingScreen.tsx
+++ b/example/screens/android/AndroidImagePreloadingScreen.tsx
@@ -2,7 +2,13 @@ import { useRouter } from 'expo-router'
import React, { useState } from 'react'
import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'
import { VoltraAndroid } from 'voltra'
-import { clearPreloadedImages, preloadImages, reloadWidgets, updateAndroidWidget } from 'voltra/android/client'
+import {
+ clearPreloadedImages,
+ preloadImages,
+ reloadWidgets,
+ updateAndroidWidget,
+ VoltraWidgetPreview,
+} from 'voltra/android/client'
import { Button } from '~/components/Button'
import { TextInput } from '~/components/TextInput'
@@ -11,12 +17,70 @@ function generateRandomUrl(): string {
return `https://picsum.photos/id/${Math.floor(Math.random() * 200)}/300/200`
}
+const ANDROID_SVG_OPTIONS = {
+ green: {
+ key: 'android-widget-svg-test-green',
+ color: '#34C759',
+ title: 'Show Green SVG in Widget',
+ },
+ purple: {
+ key: 'android-widget-svg-test-purple',
+ color: '#7C3AED',
+ title: 'Show Purple SVG in Widget',
+ },
+} as const
+
+type AndroidSvgOption = (typeof ANDROID_SVG_OPTIONS)[keyof typeof ANDROID_SVG_OPTIONS]
+
+function createAndroidTestSvg(color: string): string {
+ return `
+
+`
+}
+
+function AndroidSvgWidgetContent({ assetKey, color }: { assetKey: string; color: string }) {
+ return (
+
+
+
+
+
+
+
+ SVG preload
+
+
+ {color}
+
+
+
+
+
+ Preloaded SVG asset
+
+
+
+ )
+}
+
export default function AndroidImagePreloadingScreen() {
const router = useRouter()
const [url, setUrl] = useState(generateRandomUrl())
const [isProcessing, setIsProcessing] = useState(false)
const [assetKey] = useState('android-preload-test')
const [updateCount, setUpdateCount] = useState(0)
+ const [isSvgProcessing, setIsSvgProcessing] = useState(false)
+ const [selectedSvgOption, setSelectedSvgOption] = useState(null)
const handleUpdateAndPreload = async () => {
if (!url.trim()) {
@@ -128,6 +192,40 @@ export default function AndroidImagePreloadingScreen() {
}
}
+ const handleShowSvgWidget = async (option: AndroidSvgOption) => {
+ setIsSvgProcessing(true)
+
+ try {
+ const result = await preloadImages([
+ {
+ key: option.key,
+ svg: createAndroidTestSvg(option.color),
+ width: 48,
+ height: 48,
+ },
+ ])
+
+ if (result.failed.length > 0) {
+ throw new Error(result.failed[0].error)
+ }
+
+ await updateAndroidWidget('image_preloading', [
+ {
+ size: { width: 300, height: 200 },
+ content: ,
+ },
+ ])
+ await reloadWidgets(['image_preloading'])
+
+ setSelectedSvgOption(option)
+ Alert.alert('Success', 'SVG preloaded and widget updated!')
+ } catch (error) {
+ Alert.alert('Error', `Failed to update SVG widget: ${error}`)
+ } finally {
+ setIsSvgProcessing(false)
+ }
+ }
+
return (
@@ -165,6 +263,41 @@ export default function AndroidImagePreloadingScreen() {
+
+ SVG Widget Test
+
+ Preload an inline SVG as a PNG asset and update the Android image preloading widget to render it.
+
+
+
+
+
+ {selectedSvgOption ? (
+
+ Preview
+
+
+
+
+ ) : null}
+
+
router.push('/android-widgets')} />
@@ -216,6 +349,38 @@ const styles = StyleSheet.create({
flexDirection: 'column',
gap: 12,
},
+ section: {
+ marginTop: 24,
+ padding: 16,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: 'rgba(148, 163, 184, 0.2)',
+ backgroundColor: 'rgba(15, 23, 42, 0.8)',
+ },
+ sectionTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ sectionText: {
+ marginTop: 8,
+ fontSize: 14,
+ lineHeight: 20,
+ color: '#CBD5F5',
+ },
+ greenButtonSelected: {
+ backgroundColor: '#34C759',
+ },
+ purpleButtonSelected: {
+ backgroundColor: '#7C3AED',
+ },
+ previewContainer: {
+ marginTop: 16,
+ },
+ widgetPreview: {
+ borderRadius: 16,
+ overflow: 'hidden',
+ },
footer: {
marginTop: 24,
alignItems: 'center',
diff --git a/example/screens/testing-grounds/ImagePreloadingScreen.tsx b/example/screens/testing-grounds/ImagePreloadingScreen.tsx
index bef9e9ec..df40f0d5 100644
--- a/example/screens/testing-grounds/ImagePreloadingScreen.tsx
+++ b/example/screens/testing-grounds/ImagePreloadingScreen.tsx
@@ -2,7 +2,15 @@ import { Link } from 'expo-router'
import React, { useState } from 'react'
import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'
import { Voltra } from 'voltra'
-import { clearPreloadedImages, preloadImages, reloadLiveActivities, startLiveActivity } from 'voltra/client'
+import {
+ clearPreloadedImages,
+ preloadImages,
+ reloadLiveActivities,
+ reloadWidgets,
+ startLiveActivity,
+ updateWidget,
+ VoltraWidgetPreview,
+} from 'voltra/client'
import { Button } from '~/components/Button'
import { Card } from '~/components/Card'
@@ -12,10 +20,61 @@ function generateRandomKey(): string {
return `asset-${Math.random().toString(36).substring(2, 15)}`
}
+const SVG_OPTIONS = {
+ green: {
+ key: 'ios-widget-svg-test-green',
+ color: '#34C759',
+ title: 'Show Green SVG in Widget',
+ },
+ purple: {
+ key: 'ios-widget-svg-test-purple',
+ color: '#7C3AED',
+ title: 'Show Purple SVG in Widget',
+ },
+} as const
+
+type SvgOption = (typeof SVG_OPTIONS)[keyof typeof SVG_OPTIONS]
+
+function createTestSvg(color: string): string {
+ return `
+
+`
+}
+
+function SvgWidgetContent({ assetKey, color }: { assetKey: string; color: string }) {
+ return (
+
+
+
+
+
+ SVG preload
+ {color}
+
+
+
+
+
+ Rendered from a preloaded SVG asset
+
+
+ )
+}
+
export default function ImagePreloadingScreen() {
const [url, setUrl] = useState(`https://picsum.photos/id/${Math.floor(Math.random() * 120)}/100/100`)
const [isProcessing, setIsProcessing] = useState(false)
const [currentAssetKey, setCurrentAssetKey] = useState(null)
+ const [isSvgProcessing, setIsSvgProcessing] = useState(false)
+ const [selectedSvgOption, setSelectedSvgOption] = useState(null)
const handleShowAndDownload = async () => {
if (!url.trim()) {
@@ -91,6 +150,42 @@ export default function ImagePreloadingScreen() {
}
}
+ const handleShowSvgWidget = async (option: SvgOption) => {
+ setIsSvgProcessing(true)
+
+ try {
+ const result = await preloadImages([
+ {
+ key: option.key,
+ svg: createTestSvg(option.color),
+ width: 48,
+ height: 48,
+ },
+ ])
+
+ if (result.failed.length > 0) {
+ Alert.alert('SVG preload failed', result.failed.map((failure) => failure.error).join('\n'))
+ return
+ }
+
+ const variants = {
+ systemSmall: ,
+ systemMedium: ,
+ systemLarge: ,
+ }
+
+ await updateWidget('weather', variants)
+ await reloadWidgets(['weather'])
+
+ setSelectedSvgOption(option)
+ Alert.alert('Success', 'SVG preloaded and the Weather widget was updated.')
+ } catch (error) {
+ Alert.alert('Error', `Failed to update SVG widget: ${error}`)
+ } finally {
+ setIsSvgProcessing(false)
+ }
+ }
+
return (
@@ -127,6 +222,39 @@ export default function ImagePreloadingScreen() {
+
+ SVG Widget Test
+
+ Preload an inline SVG as a PNG asset and update the iOS Weather widget to render it with Voltra.Image.
+
+
+
+ handleShowSvgWidget(SVG_OPTIONS.green)}
+ disabled={isSvgProcessing}
+ />
+ handleShowSvgWidget(SVG_OPTIONS.purple)}
+ disabled={isSvgProcessing}
+ />
+
+
+ {selectedSvgOption ? (
+
+ Preview
+
+
+
+
+ ) : null}
+
+
@@ -147,6 +275,9 @@ const styles = StyleSheet.create({
paddingHorizontal: 20,
paddingVertical: 24,
},
+ content: {
+ gap: 8,
+ },
heading: {
fontSize: 24,
fontWeight: '700',
@@ -172,6 +303,19 @@ const styles = StyleSheet.create({
flexDirection: 'column',
gap: 12,
},
+ greenButtonSelected: {
+ backgroundColor: '#34C759',
+ },
+ purpleButtonSelected: {
+ backgroundColor: '#7C3AED',
+ },
+ previewContainer: {
+ marginTop: 16,
+ },
+ widgetPreview: {
+ borderRadius: 16,
+ overflow: 'hidden',
+ },
footer: {
marginTop: 24,
alignItems: 'center',
diff --git a/packages/android/src/types.ts b/packages/android/src/types.ts
index 24c90dd9..2369cf20 100644
--- a/packages/android/src/types.ts
+++ b/packages/android/src/types.ts
@@ -4,13 +4,24 @@ export type EventSubscription = {
remove: () => void
}
-export type PreloadImageOptions = {
- url: string
+type PreloadImageBaseOptions = {
key: string
+ width?: number
+ height?: number
+}
+
+export type PreloadImageUrlOptions = PreloadImageBaseOptions & {
+ url: string
method?: 'GET' | 'POST' | 'PUT'
headers?: Record
}
+export type PreloadImageSvgOptions = PreloadImageBaseOptions & {
+ svg: string
+}
+
+export type PreloadImageOptions = PreloadImageUrlOptions | PreloadImageSvgOptions
+
export type PreloadImageFailure = {
key: string
error: string
diff --git a/packages/ios/src/types.ts b/packages/ios/src/types.ts
index c4d08470..6359e6b5 100644
--- a/packages/ios/src/types.ts
+++ b/packages/ios/src/types.ts
@@ -4,13 +4,24 @@ export type EventSubscription = {
remove: () => void
}
-export type PreloadImageOptions = {
- url: string
+type PreloadImageBaseOptions = {
key: string
+ width?: number
+ height?: number
+}
+
+export type PreloadImageUrlOptions = PreloadImageBaseOptions & {
+ url: string
method?: 'GET' | 'POST' | 'PUT'
headers?: Record
}
+export type PreloadImageSvgOptions = PreloadImageBaseOptions & {
+ svg: string
+}
+
+export type PreloadImageOptions = PreloadImageUrlOptions | PreloadImageSvgOptions
+
export type PreloadImageFailure = {
key: string
error: string
diff --git a/packages/voltra/.prettierignore b/packages/voltra/.prettierignore
new file mode 100644
index 00000000..844e091b
--- /dev/null
+++ b/packages/voltra/.prettierignore
@@ -0,0 +1,5 @@
+build
+android/build
+ios/.build
+.expo
+coverage
diff --git a/packages/voltra/android/build.gradle b/packages/voltra/android/build.gradle
index 3fd6edb0..a3dae9ae 100644
--- a/packages/voltra/android/build.gradle
+++ b/packages/voltra/android/build.gradle
@@ -73,6 +73,7 @@ android {
compose true
}
testOptions {
+ unitTests.includeAndroidResources = true
unitTests.all {
testLogging {
events "passed", "failed", "skipped"
@@ -108,6 +109,9 @@ dependencies {
// Kotlinx Serialization
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
+ // SVG rasterization for runtime image preloading
+ implementation "com.caverock:androidsvg-aar:1.4"
+
// Unit tests
testImplementation "junit:junit:4.13.2"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1"
diff --git a/packages/voltra/android/src/main/java/voltra/VoltraModule.kt b/packages/voltra/android/src/main/java/voltra/VoltraModule.kt
index fb7c1e83..53a00925 100644
--- a/packages/voltra/android/src/main/java/voltra/VoltraModule.kt
+++ b/packages/voltra/android/src/main/java/voltra/VoltraModule.kt
@@ -391,18 +391,30 @@ class VoltraModule : Module() {
images
.map { img ->
async {
- val url = img["url"] as String
val key = img["key"] as String
+ val url = img["url"] as? String
+ val svg = img["svg"] as? String
val method = (img["method"] as? String) ?: "GET"
+ val width = (img["width"] as? Number)?.toInt()
+ val height = (img["height"] as? Number)?.toInt()
@Suppress("UNCHECKED_CAST")
val headers = img["headers"] as? Map
- val resultKey = imageManager.preloadImage(url, key, method, headers)
- if (resultKey != null) {
+ try {
+ imageManager.preloadImage(
+ key = key,
+ url = url,
+ svg = svg,
+ method = method,
+ headers = headers,
+ width = width,
+ height = height,
+ )
Pair(key, null)
- } else {
- Pair(key, "Failed to download image")
+ } catch (error: Exception) {
+ Log.e(TAG, "Error preloading image: $key", error)
+ Pair(key, error.message ?: "Failed to preload image")
}
}
}.awaitAll()
diff --git a/packages/voltra/android/src/main/java/voltra/images/VoltraImageManager.kt b/packages/voltra/android/src/main/java/voltra/images/VoltraImageManager.kt
index e06c6c62..c37e6712 100644
--- a/packages/voltra/android/src/main/java/voltra/images/VoltraImageManager.kt
+++ b/packages/voltra/android/src/main/java/voltra/images/VoltraImageManager.kt
@@ -1,14 +1,20 @@
package voltra.images
import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
import android.net.Uri
import android.util.Log
import androidx.core.content.FileProvider
+import com.caverock.androidsvg.SVG
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import java.io.ByteArrayOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
+import kotlin.math.ceil
class VoltraImageManager(
private val context: Context,
@@ -17,72 +23,24 @@ class VoltraImageManager(
private const val TAG = "VoltraImageManager"
private const val PREFS_NAME = "voltra_preload_images"
private const val CACHE_DIR_NAME = "voltra_widget_images"
+ private const val MAX_SVG_SIZE_BYTES = 256 * 1024
}
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
suspend fun preloadImage(
- url: String,
key: String,
+ url: String? = null,
+ svg: String? = null,
method: String = "GET",
headers: Map? = null,
- ): String? =
+ width: Int? = null,
+ height: Int? = null,
+ ): String =
withContext(Dispatchers.IO) {
- try {
- val connection = URL(url).openConnection() as HttpURLConnection
- connection.requestMethod = method
- headers?.forEach { (k, v) -> connection.setRequestProperty(k, v) }
-
- connection.connect()
- if (connection.responseCode !in 200..299) {
- Log.e(TAG, "Failed to download image: ${connection.responseCode}")
- return@withContext null
- }
-
- val cacheDir = File(context.cacheDir, CACHE_DIR_NAME)
- if (!cacheDir.exists()) {
- cacheDir.mkdirs()
- }
-
- // Append timestamp to force refresh
- val filename = "${key}_${System.currentTimeMillis()}.png"
- val file = File(cacheDir, filename)
-
- connection.inputStream.use { input ->
- file.outputStream().use { output ->
- input.copyTo(output)
- }
- }
-
- val uri =
- FileProvider
- .getUriForFile(
- context,
- "${context.packageName}.voltra.fileprovider",
- file,
- ).toString()
-
- // Delete old file if exists
- getUriForKey(key)?.let { oldUriString ->
- try {
- val oldUri = Uri.parse(oldUriString)
- context.contentResolver.delete(oldUri, null, null)
- // Also try to delete the file directly just in case
- val oldFilename = oldUri.lastPathSegment
- if (oldFilename != null) {
- File(cacheDir, oldFilename).delete()
- }
- } catch (e: Exception) {
- Log.w(TAG, "Failed to delete old image file: $oldUriString", e)
- }
- }
-
- prefs.edit().putString(key, uri).apply()
- return@withContext key
- } catch (e: Exception) {
- Log.e(TAG, "Error preloading image: $key", e)
- return@withContext null
- }
+ val data = resolveImageData(key, url, svg, method, headers, width, height)
+ saveImageData(key, data)
+ key
}
fun getUriForKey(key: String): String? = prefs.getString(key, null)
@@ -104,6 +62,157 @@ class VoltraImageManager(
}
}
+ private fun resolveImageData(
+ key: String,
+ url: String?,
+ svg: String?,
+ method: String,
+ headers: Map?,
+ width: Int?,
+ height: Int?,
+ ): ByteArray {
+ val inlineSvg = svg?.trim()
+ if (!inlineSvg.isNullOrEmpty()) {
+ return rasterizeSvg(key, inlineSvg, width, height)
+ }
+
+ val urlString = url?.trim()
+ require(!urlString.isNullOrEmpty()) { "Image '$key' must provide either url or svg" }
+
+ val connection = URL(urlString).openConnection() as HttpURLConnection
+ connection.requestMethod = method
+ headers?.forEach { (k, v) -> connection.setRequestProperty(k, v) }
+
+ try {
+ connection.connect()
+ require(connection.responseCode in 200..299) {
+ "HTTP error while downloading image '$key': ${connection.responseCode}"
+ }
+
+ val data = connection.inputStream.use { input -> input.readBytes() }
+ val contentType = connection.contentType
+
+ return if (isSvgData(data, contentType, urlString)) {
+ require(data.size < MAX_SVG_SIZE_BYTES) {
+ "SVG '$key' is too large: ${data.size} bytes (max $MAX_SVG_SIZE_BYTES bytes)"
+ }
+ val svgString = data.toString(Charsets.UTF_8)
+ rasterizeSvg(key, svgString, width, height)
+ } else {
+ data
+ }
+ } finally {
+ connection.disconnect()
+ }
+ }
+
+ private fun saveImageData(
+ key: String,
+ data: ByteArray,
+ ) {
+ val cacheDir = File(context.cacheDir, CACHE_DIR_NAME)
+ if (!cacheDir.exists()) {
+ cacheDir.mkdirs()
+ }
+
+ // Append timestamp to force refresh
+ val filename = "${key}_${System.currentTimeMillis()}.png"
+ val file = File(cacheDir, filename)
+ file.writeBytes(data)
+
+ val uri =
+ FileProvider
+ .getUriForFile(
+ context,
+ "${context.packageName}.voltra.fileprovider",
+ file,
+ ).toString()
+
+ // Delete old file if exists
+ getUriForKey(key)?.let { oldUriString ->
+ try {
+ val oldUri = Uri.parse(oldUriString)
+ context.contentResolver.delete(oldUri, null, null)
+ // Also try to delete the file directly just in case
+ val oldFilename = oldUri.lastPathSegment
+ if (oldFilename != null) {
+ File(cacheDir, oldFilename).delete()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to delete old image file: $oldUriString", e)
+ }
+ }
+
+ prefs.edit().putString(key, uri).apply()
+ }
+
+ private fun isSvgData(
+ data: ByteArray,
+ contentType: String?,
+ url: String,
+ ): Boolean {
+ if (contentType?.lowercase()?.contains("image/svg+xml") == true) return true
+ if (url.substringBefore("?").lowercase().endsWith(".svg")) return true
+
+ val prefix = data.copyOfRange(0, minOf(data.size, 512)).toString(Charsets.UTF_8).lowercase()
+ return prefix.contains("