ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-05-02Advanced

Building a SwiftUI Multimodal AI App with Antigravity and Gemma 4 — Camera, Photo Library, and Text Input in One Complete Guide

A complete implementation guide for building a multimodal AI app in SwiftUI using Antigravity and Gemma 4. Covers Core ML integration, camera and photo library handling, error management, and App Store privacy compliance.

Gemma 422SwiftUI9Antigravity338MultimodaliOS27Core ML2On-Device AI2

If you've ever wanted to add image understanding to your app but hesitated because of unpredictable API costs, you're not alone. I've shipped wallpaper and photo-based apps for years as a solo developer, and the same friction kept coming up: Vision API and Gemini API are powerful, but once you exhaust the free tier, cost projections become a real concern — especially if your user base grows.

Gemma 4 running on-device with Antigravity solves this at the root. The model runs locally, so API costs are zero regardless of how many users open your app. And Antigravity accelerates the implementation enough that even the tricky parts of Core ML integration become manageable in a single development session. This guide walks through building a production-ready SwiftUI app that handles camera input, photo library selection, and text prompts — all feeding into Gemma 4's multimodal inference.

On-Device vs. Cloud API — Making the Right Call for Your App

Before diving into implementation, it's worth being clear about the trade-offs so you can make the right architectural choice for your specific app.

Cloud APIs like OpenAI Vision or Gemini API offer the freshest models and the simplest client-side code. But each user interaction has a cost, inference requires a network connection, and your images pass through external servers — which matters for privacy-focused use cases and App Store privacy labels.

On-device inference with Gemma 4 flips those trade-offs. You pay nothing per inference. The app works offline. And your users' images never leave their device — a genuine advantage when writing App Store privacy nutrition labels. The downsides are a larger initial download (a few hundred MB for a quantized model) and performance that varies with device capability.

For wallpaper, photo, and creative apps — where the core value is image understanding and you want to keep marginal costs at zero — on-device is typically the right call. The Gemma 4 2B parameter model (int4 quantized) runs at a practical speed on iPhone 15 and newer.

For foundational context on Gemma 4 on-device setup, Gemma 4 × Antigravity — Complete On-Device AI Guide for iOS and Android covers the prerequisite environment setup in depth.

Setting Up the Project with Antigravity — AGENTS.md as Your Foundation

Open your project root in Antigravity and start by creating an AGENTS.md file. Antigravity reads this file to understand your project's constraints, so declaring iOS-specific requirements upfront — especially Swift Concurrency rules — dramatically improves the quality of generated code.

# AGENTS.md
 
## Project Overview
iOS app (SwiftUI, minimum target iOS 17).
Uses Gemma 4 for on-device multimodal inference. Core ML model stored in `Models/`.
 
## Coding Standards
- Use Swift Concurrency throughout (async/await, Actor isolation)
- All UI updates must happen on the main actor
- Errors use a unified `enum AppError: LocalizedError`
- ViewModels use `@MainActor` class annotation; inference runs inside `Task {}` blocks
 
## Prohibited Patterns
- No new `DispatchQueue.main.async` (unified with Structured Concurrency)
- Never assign inference results directly to `@Published` from off-actor contexts
- Never skip image orientation correction before inference
 
## Test Strategy
- XCTest for unit tests
- `GemmaInferenceManager` tested with mock dependencies
- Integration tests requiring a real model use `XCTSkip`
- Image processing (orientation fix, resize) covered as pure function tests

With this file in place, asking Antigravity to "write camera input code" returns Swift Concurrency-compliant output without needing to specify that in every prompt. Think of AGENTS.md as the upfront investment that pays dividends throughout the entire development session.

Integrating Gemma 4 and Building the Inference Manager

Add the Google AI Edge SDK for iOS via Xcode's Package Dependencies. Then place your model file (gemma-2b-it-cpu-int4.bin) in the Models/ folder and include it in your target.

Ask Antigravity to "create a GemmaInferenceManager class for multimodal inference with Gemma 4." The generated code will get you most of the way there — but verify the Swift Concurrency patterns before shipping. Here's the reference implementation:

import Foundation
import MediaPipeTasksGenAI
 
// ✅ Correct pattern: @MainActor class with Task.detached for background inference
@MainActor
class GemmaInferenceManager: ObservableObject {
    @Published var inferenceResult: String = ""
    @Published var isInferring: Bool = false
    @Published var isModelLoaded: Bool = false
    @Published var error: AppError?
 
    private var inference: LlmInference?
 
    // Load model once at startup — never on demand
    func loadModel() async throws {
        guard let modelPath = Bundle.main.path(
            forResource: "gemma-2b-it-cpu-int4",
            ofType: "bin"
        ) else {
            throw AppError.modelFileNotFound
        }
 
        let options = LlmInference.Options(modelPath: modelPath)
        options.maxTokens = 1024
        options.topk = 40
        options.temperature = 0.8
 
        // Load on a detached task so we never block the main thread
        let loaded = try await Task.detached(priority: .userInitiated) {
            try LlmInference(options: options)
        }.value
 
        self.inference = loaded
        self.isModelLoaded = true
    }
 
    // Multimodal inference — image + text prompt
    func infer(image: UIImage, prompt: String) async {
        guard let inference = inference else {
            self.error = .modelNotLoaded
            return
        }
 
        isInferring = true
        inferenceResult = ""
        error = nil
 
        do {
            // Resize before inference to limit memory usage
            let resizedImage = image.resizedForInference()
 
            guard let mpImage = try? MPImage(uiImage: resizedImage) else {
                throw AppError.imageConversionFailed
            }
 
            let fullPrompt = "<image>\n\(prompt)"
 
            // Stream tokens back in real time
            try await Task.detached(priority: .userInitiated) {
                for try await token in inference.generateResponseAsync(
                    inputText: fullPrompt,
                    inputImages: [mpImage]
                ) {
                    // Switch to main actor only for the @Published update
                    await MainActor.run {
                        self.inferenceResult += token
                    }
                }
            }.value
        } catch {
            self.error = .inferenceError(error.localizedDescription)
        }
 
        isInferring = false
    }
}
 
enum AppError: LocalizedError {
    case modelNotLoaded
    case modelFileNotFound
    case imageConversionFailed
    case inferenceError(String)
    case cameraPermissionDenied
 
    var errorDescription: String? {
        switch self {
        case .modelNotLoaded:
            return "The AI model isn't ready yet. Please wait a moment."
        case .modelFileNotFound:
            return "AI model file not found. Try reinstalling the app."
        case .imageConversionFailed:
            return "Couldn't process this image. Try a different one."
        case .inferenceError(let msg):
            return "AI inference failed: \(msg)"
        case .cameraPermissionDenied:
            return "Camera access is required. Enable it in Settings."
        }
    }
}

Common mistake #1: Skipping Task.detached and calling generateResponseAsync directly on the main actor causes the UI to freeze during inference. Always run inference on a detached task and use MainActor.run only for the UI updates. For a deeper look at Swift Concurrency patterns, Antigravity × Swift Concurrency Masterclass is a useful companion.

The Three-Input View — Camera, Photo Library, and Text

With the inference manager in place, the main view needs to handle three input types cleanly. Using an EnvironmentObject for the manager and PhotosPicker for library access keeps the view code manageable.

import SwiftUI
import PhotosUI
import AVFoundation
 
struct MultimodalInputView: View {
    @EnvironmentObject private var inferenceManager: GemmaInferenceManager
    @State private var selectedImage: UIImage?
    @State private var selectedPhotoItem: PhotosPickerItem?
    @State private var promptText: String = ""
    @State private var showCamera: Bool = false
 
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    imagePreviewSection
                    inputSourceButtons
                    promptInputSection
 
                    if !inferenceManager.isModelLoaded {
                        Label("Loading AI model...", systemImage: "hourglass")
                            .foregroundStyle(.secondary)
                            .font(.callout)
                    }
 
                    resultSection
                }
                .padding()
            }
            .navigationTitle("AI Image Analyzer")
            .sheet(isPresented: $showCamera) {
                CameraView(capturedImage: $selectedImage)
            }
            .onChange(of: selectedPhotoItem) { _, newItem in
                Task {
                    if let data = try? await newItem?.loadTransferable(type: Data.self),
                       let image = UIImage(data: data) {
                        await MainActor.run {
                            // Always fix orientation from photo library images
                            selectedImage = image.fixedOrientation()
                        }
                    }
                }
            }
        }
    }
 
    // MARK: - Subviews
 
    @ViewBuilder
    private var imagePreviewSection: some View {
        if let image = selectedImage {
            Image(uiImage: image)
                .resizable()
                .scaledToFit()
                .frame(maxHeight: 280)
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .overlay(alignment: .topTrailing) {
                    Button {
                        selectedImage = nil
                        inferenceManager.inferenceResult = ""
                    } label: {
                        Image(systemName: "xmark.circle.fill")
                            .font(.title2)
                            .foregroundStyle(.white)
                            .background(Circle().fill(.black.opacity(0.5)))
                    }
                    .padding(8)
                }
        } else {
            RoundedRectangle(cornerRadius: 12)
                .fill(Color(.systemGray6))
                .frame(height: 200)
                .overlay {
                    VStack(spacing: 8) {
                        Image(systemName: "photo.on.rectangle")
                            .font(.system(size: 40))
                            .foregroundStyle(.secondary)
                        Text("Select an image to begin")
                            .font(.callout)
                            .foregroundStyle(.secondary)
                    }
                }
        }
    }
 
    private var inputSourceButtons: some View {
        HStack(spacing: 12) {
            Button {
                checkCameraPermissionAndOpen()
            } label: {
                Label("Camera", systemImage: "camera")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.bordered)
 
            PhotosPicker(
                selection: $selectedPhotoItem,
                matching: .images,
                photoLibrary: .shared()
            ) {
                Label("Library", systemImage: "photo.on.rectangle.angled")
                    .frame(maxWidth: .infinity)
            }
            .buttonStyle(.bordered)
        }
    }
 
    private var promptInputSection: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Prompt (optional)")
                .font(.caption)
                .foregroundStyle(.secondary)
            TextField("Ask something about this image...", text: $promptText, axis: .vertical)
                .textFieldStyle(.roundedBorder)
                .lineLimit(3...6)
        }
    }
 
    private var resultSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            if inferenceManager.isInferring {
                HStack {
                    ProgressView()
                    Text("Analyzing with AI...")
                        .foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity)
            }
 
            if !inferenceManager.inferenceResult.isEmpty {
                GroupBox("AI Analysis") {
                    Text(inferenceManager.inferenceResult)
                        .font(.body)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .textSelection(.enabled)
                }
            }
 
            if let error = inferenceManager.error {
                Label(error.localizedDescription, systemImage: "exclamationmark.triangle")
                    .foregroundStyle(.red)
                    .font(.callout)
            }
 
            if selectedImage != nil {
                Button {
                    runInference()
                } label: {
                    Label("Analyze with AI", systemImage: "sparkles")
                        .frame(maxWidth: .infinity)
                }
                .buttonStyle(.borderedProminent)
                .disabled(inferenceManager.isInferring || !inferenceManager.isModelLoaded)
            }
        }
    }
 
    // MARK: - Private Methods
 
    private func checkCameraPermissionAndOpen() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            showCamera = true
        case .notDetermined:
            Task {
                let granted = await AVCaptureDevice.requestAccess(for: .video)
                await MainActor.run {
                    if granted { showCamera = true }
                    else { inferenceManager.error = .cameraPermissionDenied }
                }
            }
        default:
            inferenceManager.error = .cameraPermissionDenied
        }
    }
 
    private func runInference() {
        guard let image = selectedImage else { return }
        let prompt = promptText.trimmingCharacters(in: .whitespacesAndNewlines)
        let finalPrompt = prompt.isEmpty ? "Describe this image in detail." : prompt
        Task {
            await inferenceManager.infer(image: image, prompt: finalPrompt)
        }
    }
}

Common mistake #2: PhotosPicker's onChange callback gives you a PhotosPickerItem, not a UIImage. You must call loadTransferable(type: Data.self) and then convert to UIImage. And since library photos frequently have non-.up orientations (especially from older camera rolls), fixedOrientation() is not optional.

CameraView — AVFoundation Implementation

The camera piece requires UIViewControllerRepresentable since SwiftUI doesn't have a native camera view. Ask Antigravity for "a UIViewControllerRepresentable that wraps UIImagePickerController and returns the captured image via @Binding var capturedImage: UIImage?."

import AVFoundation
import SwiftUI
 
struct CameraView: UIViewControllerRepresentable {
    @Binding var capturedImage: UIImage?
    @Environment(\.dismiss) private var dismiss
 
    func makeUIViewController(context: Context) -> UIImagePickerController {
        let picker = UIImagePickerController()
        picker.sourceType = .camera
        picker.delegate = context.coordinator
        picker.allowsEditing = false
        return picker
    }
 
    func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
 
    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
 
    class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
        let parent: CameraView
 
        init(parent: CameraView) {
            self.parent = parent
        }
 
        func imagePickerController(
            _ picker: UIImagePickerController,
            didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
        ) {
            if let image = info[.originalImage] as? UIImage {
                // ✅ Always fix orientation before storing
                parent.capturedImage = image.fixedOrientation()
            }
            parent.dismiss()
        }
 
        func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
            parent.dismiss()
        }
    }
}
 
// MARK: - UIImage Utilities
 
extension UIImage {
    /// Normalize orientation metadata from camera captures
    func fixedOrientation() -> UIImage {
        guard imageOrientation != .up else { return self }
        UIGraphicsBeginImageContextWithOptions(size, false, scale)
        defer { UIGraphicsEndImageContext() }
        draw(in: CGRect(origin: .zero, size: size))
        return UIGraphicsGetImageFromCurrentImageContext() ?? self
    }
 
    /// Resize to fit within maxDimension before inference (memory + speed optimization)
    func resizedForInference(maxDimension: CGFloat = 1024) -> UIImage {
        let longSide = max(size.width, size.height)
        guard longSide > maxDimension else { return self }
        let scale = maxDimension / longSide
        let newSize = CGSize(width: size.width * scale, height: size.height * scale)
        return UIGraphicsImageRenderer(size: newSize).image { _ in
            draw(in: CGRect(origin: .zero, size: newSize))
        }
    }
}

Common mistake #3: Passing a raw camera-captured UIImage to Core ML without orientation correction causes the model to analyze a rotated image. The AI response will be semantically off — sometimes subtly, sometimes obviously wrong. fixedOrientation() is a small function with an outsized impact on inference quality.

App Entry Point — Preloading the Model at Launch

The timing of model loading is a UX decision as much as a technical one. Loading on first inference creates a multi-second freeze the first time a user taps the analyze button. Loading at launch hides the wait behind the app's startup sequence.

@main
struct MultimodalAIApp: App {
    @StateObject private var inferenceManager = GemmaInferenceManager()
 
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(inferenceManager)
                .task {
                    // ✅ Start loading immediately — runs while the first view renders
                    do {
                        try await inferenceManager.loadModel()
                    } catch {
                        // GemmaInferenceManager already stores the error in @Published error
                        // ContentView will pick it up automatically via @EnvironmentObject
                        print("Model load failed: \(error.localizedDescription)")
                    }
                }
        }
    }
}

The Gemma 4 2B int4 model typically loads in 1–2 seconds on iPhone 15. If you pair this with a splash screen or a short onboarding animation, users never experience it as a wait. The isModelLoaded flag in the manager controls the disabled state of the analyze button, so there's no risk of a user triggering inference before the model is ready.

Pitfalls Worth Knowing Before You Ship

A few failure modes come up consistently in on-device AI apps that aren't obvious from the documentation alone.

Parallel inference requests: If a user taps the analyze button quickly multiple times, inference tasks can pile up and produce interleaved output. The isInferring flag handles this at the UI level — disabled(inferenceManager.isInferring) — but you may also want a Task handle that can be cancelled if a new request comes in.

Out-of-memory on older devices: The 2B int4 model can trigger OOM errors on iPhone 12 and earlier. Your do-catch around inference already handles this, but the error message surfaced to the user matters. "Not enough memory to run the AI model. Try closing other apps first." is more actionable than a generic failure message.

Large photo library images: Photos taken with the main camera on recent iPhones can exceed 48 megapixels. Passing these directly to inference produces very slow results and risks memory pressure. The resizedForInference() function in the code above caps the longest dimension at 1024px — do not skip this step.

Streaming output and scroll position: As tokens stream in and the result text grows, ScrollView may not scroll to show new content automatically. Adding ScrollViewReader with .onChange(of: inferenceManager.inferenceResult) to scroll to the bottom of the result area gives a much more responsive feel.

Automating Tests with Antigravity's Background Agent

Once the core logic is working, hand testing responsibility to Antigravity's Background Agent. You can continue building the UI while the background agent writes test cases in parallel.

Give the agent a specific instruction rather than a general one: "Write XCTest unit tests for GemmaInferenceManager. Define a MockLlmInference protocol to decouple the real model dependency. Test that modelNotLoaded, imageConversionFailed, and inferenceError cases correctly populate the @Published error property. Mark any tests that require real model inference with XCTSkip."

Specificity in the error cases you want covered is the key. A vague "write tests for the inference manager" prompt typically produces only happy-path tests. Naming the error cases you care about gives the agent enough signal to generate the coverage that actually matters.

App Store Privacy and AI Disclosure

On-device AI apps have a meaningful advantage in the App Store privacy review: images never leave the device, so your privacy nutrition label can honestly say you collect no data.

Required Info.plist entries:

Performance Profiling — Measuring What Actually Matters

Once you have a working implementation, it's worth profiling before shipping rather than after your first negative review about "slowness." The metrics that matter for on-device inference apps are different from typical network-bound apps.

Time to first token: The interval between tapping the analyze button and seeing the first token appear. This is what users experience as "lag." Target under 3 seconds for the Gemma 4 2B model on supported hardware. Use CFAbsoluteTimeGetCurrent() around the generateResponseAsync call to measure this during development.

Peak memory during inference: Use Xcode's Memory Debugger or Instruments with the Allocations template. Gemma 4 2B int4 typically peaks around 1.2–1.8 GB on device. If your app also holds large images in memory at the same time, you're competing for a finite pool. The resizedForInference() function addresses the image side of this; consider releasing references to the full-resolution image once you've created the resized version.

Thermal state impact: Sustained inference on mobile devices generates heat, which triggers thermal throttling. The CPU and Neural Engine slow down to protect the hardware. If users are running consecutive inferences, you may see the second and third calls take significantly longer than the first. Apple's ProcessInfo.thermalState API lets you check the current thermal state and adapt your UI accordingly — for example, showing a subtle "Cooling down..." message rather than leaving users guessing why inference is slow.

// Thermal state awareness
import Foundation
 
func thermalStateDescription() -> String {
    switch ProcessInfo.processInfo.thermalState {
    case .nominal:
        return "Normal"
    case .fair:
        return "Slightly warm — performance may vary"
    case .serious:
        return "Warm — AI performance is reduced"
    case .critical:
        return "Hot — AI temporarily unavailable"
    @unknown default:
        return "Unknown"
    }
}
 
// In your ViewModel, subscribe to thermal notifications:
NotificationCenter.default.publisher(
    for: ProcessInfo.thermalStateDidChangeNotification
)
.receive(on: RunLoop.main)
.sink { [weak self] _ in
    // Update UI if thermal state changes during inference
    self?.thermalWarning = ProcessInfo.processInfo.thermalState == .critical
}
.store(in: &cancellables)

The thermal state notification approach is especially important for wallpaper generation or image batch processing scenarios where a user might analyze multiple images in succession.

Building a Sustainable Monetization Architecture

Zero marginal cost on inference is a competitive advantage, but it doesn't mean you should give unlimited inference for free. A well-designed monetization structure for an on-device AI app typically looks like this:

Free tier: A daily inference quota (5–10 analyses per day) tracked in UserDefaults or SwiftData. Reset at midnight local time using a timestamp comparison. This gives users enough to evaluate the feature without being able to use it without limit.

Consumable purchase: Additional inference packs (e.g., 50 analyses for $0.99) as StoreKit 2 consumables. Because inference happens on-device, there's no server-side validation needed — the count is purely local. This is both a simplification and a risk (users can reset it by clearing app data), but for most indie apps the trade-off is worth it.

Subscription unlock: Unlimited daily inferences for subscribers. Use StoreKit 2's Transaction.currentEntitlements to check subscription status on each app launch and store the result in a @AppStorage boolean for fast access throughout the app.

import StoreKit
 
@MainActor
class PurchaseManager: ObservableObject {
    @Published var hasUnlimitedAccess: Bool = false
    @Published var remainingDailyInferences: Int = 5
 
    private let dailyLimit = 5
    private let lastResetKey = "lastInferenceResetDate"
    private let countKey = "dailyInferenceCount"
 
    func checkEntitlements() async {
        // Check active subscriptions via StoreKit 2
        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                if transaction.productType == .autoRenewable {
                    hasUnlimitedAccess = true
                    return
                }
            }
        }
        hasUnlimitedAccess = false
    }
 
    func canRunInference() -> Bool {
        if hasUnlimitedAccess { return true }
        resetDailyCountIfNeeded()
        let count = UserDefaults.standard.integer(forKey: countKey)
        return count < dailyLimit
    }
 
    func recordInferenceUsed() {
        guard !hasUnlimitedAccess else { return }
        let count = UserDefaults.standard.integer(forKey: countKey)
        UserDefaults.standard.set(count + 1, forKey: countKey)
        remainingDailyInferences = max(0, dailyLimit - count - 1)
    }
 
    private func resetDailyCountIfNeeded() {
        let lastReset = UserDefaults.standard.object(forKey: lastResetKey) as? Date ?? .distantPast
        if !Calendar.current.isDateInToday(lastReset) {
            UserDefaults.standard.set(0, forKey: countKey)
            UserDefaults.standard.set(Date(), forKey: lastResetKey)
            remainingDailyInferences = dailyLimit
        }
    }
}

Integrate this with your inference flow by checking canRunInference() before calling inferenceManager.infer() and calling recordInferenceUsed() after a successful inference starts. Show a paywall sheet when the daily limit is reached rather than disabling the button silently — giving users a clear path to unlock more usage converts better than a disabled button with no explanation.

SwiftData for Inference History

Returning users appreciate being able to look back at previous analyses. SwiftData makes this straightforward with minimal boilerplate.

import SwiftData
import SwiftUI
 
// Define the model
@Model
class InferenceRecord {
    var id: UUID
    var imageData: Data?       // Thumbnail — not full resolution
    var prompt: String
    var result: String
    var createdAt: Date
 
    init(imageData: Data?, prompt: String, result: String) {
        self.id = UUID()
        self.imageData = imageData
        self.prompt = prompt
        self.result = result
        self.createdAt = Date()
    }
}
 
// In your App struct, add the model container
@main
struct MultimodalAIApp: App {
    @StateObject private var inferenceManager = GemmaInferenceManager()
 
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(inferenceManager)
                .modelContainer(for: InferenceRecord.self)
                .task {
                    try? await inferenceManager.loadModel()
                }
        }
    }
}

Store only a downscaled thumbnail (e.g., 200px max dimension) rather than the full image — this keeps the database size manageable over time. Use UIImage's jpegData(compressionQuality: 0.6) on the resized thumbnail before writing to SwiftData.

This history view also becomes a natural hook for sharing features (using ShareLink) and for prompting users to subscribe when they hit the daily limit while scrolling through past results.

Where to Go From Here

The implementation in this guide gives you a production-ready multimodal app with real edge case handling. The natural next steps depend on your app's specific direction:

If you're building for image understanding (photo journaling, visual search, accessibility descriptions), the next investment is in prompt engineering — crafting system prompts that guide Gemma 4 toward the output format and tone that fits your use case.

Unit Testing the Image Processing Utilities

The image utility functions — fixedOrientation() and resizedForInference() — are pure functions that don't require a real device or model to test. These are easy wins for your test suite that catch regressions before they reach production.

import XCTest
@testable import MultimodalAIApp
 
final class UIImageExtensionTests: XCTestCase {
 
    func testFixedOrientationReturnsUpOrientedImage() throws {
        // Create a test image with a non-up orientation
        let size = CGSize(width: 200, height: 300)
        let renderer = UIGraphicsImageRenderer(size: size)
        let baseImage = renderer.image { context in
            UIColor.blue.setFill()
            context.fill(CGRect(origin: .zero, size: size))
        }
 
        // Simulate a left-rotated orientation (common from older camera APIs)
        guard let cgImage = baseImage.cgImage else {
            XCTFail("Failed to get CGImage")
            return
        }
        let rotatedImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: .left)
        XCTAssertNotEqual(rotatedImage.imageOrientation, .up)
 
        let fixed = rotatedImage.fixedOrientation()
        XCTAssertEqual(fixed.imageOrientation, .up)
    }
 
    func testResizeReducesLargeImage() {
        // Create a 2000x2000 image (larger than the 1024px inference limit)
        let size = CGSize(width: 2000, height: 2000)
        let renderer = UIGraphicsImageRenderer(size: size)
        let largeImage = renderer.image { context in
            UIColor.red.setFill()
            context.fill(CGRect(origin: .zero, size: size))
        }
 
        let resized = largeImage.resizedForInference(maxDimension: 1024)
        XCTAssertLessThanOrEqual(resized.size.width, 1024)
        XCTAssertLessThanOrEqual(resized.size.height, 1024)
    }
 
    func testResizeDoesNotUpscaleSmallImages() {
        // A 400x300 image should pass through unchanged
        let size = CGSize(width: 400, height: 300)
        let renderer = UIGraphicsImageRenderer(size: size)
        let smallImage = renderer.image { context in
            UIColor.green.setFill()
            context.fill(CGRect(origin: .zero, size: size))
        }
 
        let result = smallImage.resizedForInference(maxDimension: 1024)
        XCTAssertEqual(result.size.width, 400)
        XCTAssertEqual(result.size.height, 300)
    }
 
    func testResizeMaintainsAspectRatio() {
        // A 3000x1500 landscape image should resize to 1024x512
        let size = CGSize(width: 3000, height: 1500)
        let renderer = UIGraphicsImageRenderer(size: size)
        let wideImage = renderer.image { context in
            UIColor.orange.setFill()
            context.fill(CGRect(origin: .zero, size: size))
        }
 
        let resized = wideImage.resizedForInference(maxDimension: 1024)
        let aspectRatio = resized.size.width / resized.size.height
        XCTAssertEqual(aspectRatio, 2.0, accuracy: 0.01,
            "Aspect ratio should be preserved after resize")
    }
}

Ask the Antigravity background agent to extend this suite with edge cases you haven't thought of — zero-size images, extremely tall portrait images, images already at exactly the max dimension. The agent is particularly good at finding boundary conditions once it understands the function signatures and constraints.

Start today by opening Antigravity, creating your AGENTS.md, and asking it to generate the base GemmaInferenceManager. With Antigravity handling the scaffolding, a working prototype with all three input types is realistically achievable in a day or two of focused development sessions.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-04-16
Google AI Edge Eloquent: Running Google's AI Models Natively on iOS
Google AI Edge Eloquent is Google's framework for running Gemma and other Google models locally on iOS and Apple Silicon. Here's how it works, how it differs from Core ML, and how to implement it with Antigravity.
App Dev2026-03-18
Building Production iCloud Sync Apps with SwiftUI and CloudKit in Antigravity
Build a production iCloud sync iOS app with SwiftUI and CloudKit in Antigravity. Covers CKRecord design, offline support, and the queryable-index and Development-to-Production schema pitfalls that break release builds.
App Dev2026-07-04
Paid, but the Ads Won't Go Away — Closing a StoreKit 2 Transaction.updates Launch Race with Antigravity
How I traced a StoreKit 2 launch race that dropped transactions arriving right after startup, pinned the listener to the app's lifetime instead of a view's, reconciled with currentEntitlements, and let Antigravity turn it into a StoreKitTest regression suite.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →