Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,86 @@ import * as fs from 'fs'
import * as path from 'path'
import { vdConvert } from 'vd-tool'

import { MAX_IMAGE_SIZE_BYTES } from '../../../../constants'
import { logger } from '../../../../utils'
import { DEFAULT_ANDROID_USER_IMAGES_PATH, MAX_IMAGE_SIZE_BYTES } from '../../constants'
import type { AndroidWidgetConfig } from '../../types'
import { logger } from '../../utils/logger'

export interface GenerateAndroidAssetsOptions {
platformProjectRoot: string
projectRoot: string
userImagesPath?: string
widgets: AndroidWidgetConfig[]
}

// ============================================================================
// Main Function
// ============================================================================

/**
* Generates all asset files for Android widgets.
*
* This creates:
* - res/drawable/ directory structure
* - Copies user images to drawable resources as Android-compatible resources
* - Copies widget preview images to drawable resources
* - Validates image sizes for widget compatibility
*
* @returns Map of widgetId to preview image drawable resource name
*/
export async function generateAndroidAssets(options: GenerateAndroidAssetsOptions): Promise<Map<string, string>> {
const { platformProjectRoot, projectRoot, userImagesPath = DEFAULT_ANDROID_USER_IMAGES_PATH, widgets } = options

// Create res/drawable directory
const drawablePath = path.join(platformProjectRoot, 'app', 'src', 'main', 'res', 'drawable')
logger.info(`Generating Android assets in ${drawablePath}`)

// Collect asset paths from the user images directory (including subdirectories)
const assets: string[] = []
const fullUserImagesPath = path.join(projectRoot, userImagesPath)

function collectAssetsRecursively(dirPath: string, relativeBase: string) {
if (!fs.existsSync(dirPath) || !fs.lstatSync(dirPath).isDirectory()) {
return
}

const files = fs.readdirSync(dirPath)
for (const file of files) {
const sourcePath = path.join(dirPath, file)
const stat = fs.lstatSync(sourcePath)

if (stat.isDirectory()) {
// Recursively collect from subdirectories
collectAssetsRecursively(sourcePath, relativeBase)
} else {
// Add relative path to assets array
const relativePath = path.relative(projectRoot, sourcePath)
assets.push(relativePath)
}
}
}

collectAssetsRecursively(fullUserImagesPath, userImagesPath)

// Copy user images to drawable resources
const copiedImages = await copyUserImagesToAndroid(assets, projectRoot, drawablePath)

if (copiedImages.length > 0) {
logger.info(`Copied ${copiedImages.length} user image(s) to Android drawable resources`)
}

// Copy preview images to drawable resources
const previewImageMap = await copyPreviewImagesToAndroid(widgets, projectRoot, drawablePath)

if (previewImageMap.size > 0) {
logger.info(`Copied ${previewImageMap.size} preview image(s) to Android drawable resources`)
}

return previewImageMap
}

// ============================================================================
// Image Handling
// ============================================================================

// Android supports these image/drawable extensions
const VALID_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.svg']
Expand All @@ -15,7 +93,7 @@ const VALID_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.sv
* @param imagePath - Path to the image file
* @returns true if the image is within the size limit, false otherwise
*/
export function checkImageSize(imagePath: string): boolean {
function checkImageSize(imagePath: string): boolean {
const stats = fs.statSync(imagePath)
const imageSizeInBytes = stats.size

Expand Down Expand Up @@ -110,11 +188,7 @@ function processBitmapImage(sourcePath: string, destinationPath: string): void {
* @param drawablePath - Path to the res/drawable directory in the Android project
* @returns Array of drawable resource names that were copied
*/
export async function copyUserImagesToAndroid(
assets: string[],
projectRoot: string,
drawablePath: string
): Promise<string[]> {
async function copyUserImagesToAndroid(assets: string[], projectRoot: string, drawablePath: string): Promise<string[]> {
const copiedImages: string[] = []

// Ensure drawable directory exists
Expand Down Expand Up @@ -158,3 +232,56 @@ export async function copyUserImagesToAndroid(

return copiedImages
}

// ============================================================================
// Preview Images
// ============================================================================

/**
* Copies preview images to Android drawable resources.
* Returns a map of widgetId to drawable resource name.
*/
async function copyPreviewImagesToAndroid(
widgets: AndroidWidgetConfig[],
projectRoot: string,
drawablePath: string
): Promise<Map<string, string>> {
const previewImageMap = new Map<string, string>()

// Ensure drawable directory exists
if (!fs.existsSync(drawablePath)) {
fs.mkdirSync(drawablePath, { recursive: true })
}

for (const widget of widgets) {
if (!widget.previewImage) {
continue
}

const sourcePath = path.join(projectRoot, widget.previewImage)

// Validate file exists
if (!fs.existsSync(sourcePath)) {
logger.warn(`Preview image not found for widget '${widget.id}' at ${widget.previewImage}`)
continue
}

// Check image size
checkImageSize(sourcePath)

// Generate drawable resource name
const ext = path.extname(widget.previewImage).toLowerCase()
const drawableName = `voltra_widget_${widget.id}_preview`
const destinationPath = path.join(drawablePath, `${drawableName}${ext}`)

// Copy file
fs.copyFileSync(sourcePath, destinationPath)
logger.info(`Copied preview image for widget '${widget.id}' to ${drawableName}${ext}`)

previewImageMap.set(widget.id, drawableName)
}

return previewImageMap
}

export { copyPreviewImagesToAndroid }
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ConfigPlugin, withDangerousMod } from '@expo/config-plugins'

import type { AndroidWidgetConfig } from '../../../types'
import type { AndroidWidgetConfig } from '../../types'
import { generateAndroidAssets } from './assets'
import { generateInitialStates } from './initialStates'
import { generateKotlinFiles } from './kotlin'
import { generateXmlFiles } from './xml'
import { generateAndroidInitialStates } from './initialStates'
import { generateWidgetReceivers } from './kotlin'
import { generateWidgetInfoFiles, generateWidgetPlaceholderLayouts, generateWidgetPreviewLayouts } from './xml'

export interface GenerateAndroidWidgetFilesProps {
widgets: AndroidWidgetConfig[]
Expand All @@ -29,8 +29,18 @@ export const generateAndroidWidgetFiles: ConfigPlugin<GenerateAndroidWidgetFiles
return withDangerousMod(config, [
'android',
async (config) => {
if (config.modRequest.introspect) {
return config
}

const { platformProjectRoot, projectRoot } = config.modRequest
const packageName = config.android?.package || 'com.example.app'
const packageName = config.android?.package

if (!packageName) {
throw new Error(
'Voltra config plugin requires expo.android.package to be set in app.json/app.config.* to configure Android widgets.'
)
}

// Generate assets (drawable images and preview images)
const previewImageMap = await generateAndroidAssets({
Expand All @@ -41,22 +51,31 @@ export const generateAndroidWidgetFiles: ConfigPlugin<GenerateAndroidWidgetFiles
})

// Generate Kotlin receiver classes
await generateKotlinFiles({
await generateWidgetReceivers({
platformProjectRoot,
packageName,
widgets,
})

// Generate XML files (widget info, layouts, strings, preview layouts)
await generateXmlFiles({
// Generate XML files (widget info, layouts, strings)
await generateWidgetInfoFiles({
platformProjectRoot,
widgets,
})

await generateWidgetPlaceholderLayouts({
platformProjectRoot,
})

await generateWidgetPreviewLayouts({
platformProjectRoot,
projectRoot,
widgets,
previewImageMap,
})

// Generate initial states (pre-rendered widgets)
await generateInitialStates({
await generateAndroidInitialStates({
platformProjectRoot,
projectRoot: config.modRequest.projectRoot,
widgets,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import fs from 'fs'
import path from 'path'

import type { AndroidWidgetConfig } from '../../../types'
import { logger } from '../../../utils'
import { prerenderWidgetState } from '../../../utils/prerender'
import type { AndroidWidgetConfig } from '../../types'
import { logger } from '../../utils/logger'
import { prerenderWidgetState } from '../../utils/prerender'

export interface GenerateInitialStatesOptions {
widgets: AndroidWidgetConfig[]
Expand All @@ -17,14 +17,13 @@ export interface GenerateInitialStatesOptions {
* This file (voltra_initial_states.json) is placed in the Android assets directory
* and contains the pre-rendered payloads for all widgets that have an initialStatePath.
*/
export async function generateInitialStates(options: GenerateInitialStatesOptions): Promise<void> {
export async function generateAndroidInitialStates(options: GenerateInitialStatesOptions): Promise<void> {
const { widgets, projectRoot, platformProjectRoot } = options

// Dynamic import for ESM module compatibility
// voltra/android/server is an ESM module, but the plugin is compiled to CommonJS
const { renderAndroidWidgetToString } = await import('voltra/android/server')


// Prerender widget states
const prerenderedStates = await prerenderWidgetState(widgets, projectRoot, renderAndroidWidgetToString)

Expand Down
66 changes: 66 additions & 0 deletions plugin/src/android/files/kotlin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import dedent from 'dedent'
import * as fs from 'fs'
import * as path from 'path'

import type { AndroidWidgetConfig } from '../../types'

export interface GenerateKotlinFilesProps {
platformProjectRoot: string
packageName: string
widgets: AndroidWidgetConfig[]
}

// ============================================================================
// Main Function
// ============================================================================

/**
* Generates Kotlin receiver classes for all configured widgets
*/
export async function generateWidgetReceivers(props: GenerateKotlinFilesProps): Promise<void> {
const { platformProjectRoot, packageName, widgets } = props

// Determine the package path (e.g., com.example.app -> com/example/app)
const packagePath = packageName.replace(/\./g, '/')
const widgetDir = path.join(platformProjectRoot, 'app', 'src', 'main', 'java', packagePath, 'widget')

// Ensure the widget directory exists
if (!fs.existsSync(widgetDir)) {
fs.mkdirSync(widgetDir, { recursive: true })
}

// Generate a receiver class for each widget
for (const widget of widgets) {
const className = `VoltraWidget_${widget.id}Receiver`
const filePath = path.join(widgetDir, `${className}.kt`)
const content = generateWidgetReceiverClass(widget, packageName)

fs.writeFileSync(filePath, content, 'utf8')
}
}

// ============================================================================
// Widget Receiver
// ============================================================================

/**
* Generates Kotlin code for a single widget receiver class
*/
function generateWidgetReceiverClass(widget: AndroidWidgetConfig, packageName: string): string {
// Sanitize the widget id for use as a Kotlin class name
const className = `VoltraWidget_${widget.id}Receiver`

return dedent`
package ${packageName}.widget

import voltra.widget.VoltraWidgetReceiver

/**
* Auto-generated widget receiver for ${widget.displayName}
* Widget ID: ${widget.id}
*/
class ${className} : VoltraWidgetReceiver() {
override val widgetId: String = "${widget.id}"
}
`
}
Loading
Loading