-
Never bundle API keys in the app. Mobile app binaries can be decompiled. Anyone with the APK or IPA can extract hardcoded keys.
Instead, use a backend proxy:
Mobile App --> Your Backend --> AI Provider
(holds API key)
The backend holds the API key. The mobile app authenticates with your backend using user auth tokens.
-
Set up a minimal backend proxy. Example with Cloudflare Workers:
export default {
async fetch(request: Request): Promise<Response> {
const { prompt, image } = await request.json();
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: "gpt-4o",
messages: [
{
role: "user",
content: image
? [
{ type: "text", text: prompt },
{ type: "image_url", image_url: { url: image } },
]
: prompt,
},
],
max_tokens: 1024,
}),
});
return response;
},
};
Deploy this and point your app at its URL.
-
Create an API client in the app. In lib/ai.ts:
const API_BASE = "https://your-worker.your-domain.workers.dev";
interface AIResponse {
text: string;
usage: { prompt_tokens: number; completion_tokens: number };
}
export async function generateText(
prompt: string,
authToken: string,
): Promise<AIResponse> {
const res = await fetch(`${API_BASE}/generate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ prompt }),
});
if (!res.ok) {
const error = await res.text();
throw new Error(`AI request failed: ${res.status} ${error}`);
}
return res.json();
}
export async function analyzeImage(
imageBase64: string,
prompt: string,
authToken: string,
): Promise<AIResponse> {
const res = await fetch(`${API_BASE}/vision`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
prompt,
image: `data:image/jpeg;base64,${imageBase64}`,
}),
});
if (!res.ok) throw new Error(`Vision request failed: ${res.status}`);
return res.json();
}
-
Use vision with expo-camera. Capture a photo and send it to the vision API:
import { CameraView } from "expo-camera";
import { useRef } from "react";
import { analyzeImage } from "@/lib/ai";
const cameraRef = useRef<CameraView>(null);
async function captureAndAnalyze() {
if (!cameraRef.current) return;
const photo = await cameraRef.current.takePictureAsync({
base64: true,
quality: 0.5,
});
if (!photo?.base64) return;
const result = await analyzeImage(
photo.base64,
"Describe what you see in this image.",
userAuthToken,
);
console.log(result.text);
}
Use quality: 0.5 or lower to reduce payload size. A full-resolution photo can be 5MB+ in base64.
-
Streaming responses. For chat UIs, stream tokens as they arrive:
export async function streamText(
prompt: string,
authToken: string,
onToken: (token: string) => void,
): Promise<void> {
const res = await fetch(`${API_BASE}/stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ prompt, stream: true }),
});
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
onToken(chunk);
}
}
Usage in a component:
const [response, setResponse] = useState("");
async function handleSend() {
setResponse("");
await streamText(prompt, authToken, (token) => {
setResponse((prev) => prev + token);
});
}
-
Audio transcription. Record audio with expo-av and send to Whisper:
npx expo install expo-av
import { Audio } from "expo-av";
const [recording, setRecording] = useState<Audio.Recording | null>(null);
async function startRecording() {
await Audio.requestPermissionsAsync();
await Audio.setAudioModeAsync({ allowsRecordingIOS: true });
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY,
);
setRecording(recording);
}
async function stopAndTranscribe() {
if (!recording) return;
await recording.stopAndUnloadAsync();
const uri = recording.getURI();
if (!uri) return;
const formData = new FormData();
formData.append("file", {
uri,
type: "audio/m4a",
name: "recording.m4a",
} as any);
const res = await fetch(`${API_BASE}/transcribe`, {
method: "POST",
headers: { Authorization: `Bearer ${authToken}` },
body: formData,
});
const { text } = await res.json();
console.log("Transcription:", text);
}