P247 iOS Brief: Activity Images
P247 iOS Brief: Activity Images
Date: 27 March 2026 Priority: High (feature is live on backend, blocked on this fix) Backend status: Complete and deployed
What’s Live
The backend now supports image uploads on activities. Athletes can attach up to 5 images per activity with three type tags:
- program — gym whiteboard / workout plan photos. Backend runs Claude vision to extract exercises, sets, reps, and weights into structured JSON automatically.
- form — selfies, form check photos, progress shots.
- moment — scenic photos, milestones, memories tied to an activity.
Images are served with thumbnails, and the activity detail endpoint includes an images array.
The Issue: EXIF Metadata is Being Stripped
All images uploaded so far arrive with zero EXIF data. No GPS coordinates, no datetime, no camera info. The backend has EXIF extraction built in (reads GPS + DateTimeOriginal on upload), but it can’t work if the data isn’t in the bytes.
Root cause: The iOS app is likely converting images through UIImage before uploading, which strips EXIF metadata. This happens with:
UIImage(data:).jpegData(compressionQuality:)— strips EXIFUIImagePickerControllerresult → UIImage — strips EXIF- Any
UIImage→Dataconversion — strips EXIF
The Fix
Send the original file data from the photo library, not a UIImage re-encoding.
Option A: PhotosPicker (SwiftUI, preferred)
import PhotosUI
// In your PhotosPicker handler:
func handleSelection(_ item: PhotosPickerItem?) async {
guard let item else { return }
// This preserves EXIF metadata
guard let data = try? await item.loadTransferable(type: Data.self) else { return }
// Upload 'data' directly — do NOT convert to UIImage first
await uploadImage(data: data, mimeType: "image/jpeg")
}
Option B: PHAssetResourceManager (UIKit)
import Photos
func getOriginalImageData(asset: PHAsset, completion: @escaping (Data?) -> Void) {
guard let resource = PHAssetResource.assetResources(for: asset).first else {
completion(nil)
return
}
var imageData = Data()
let options = PHAssetResourceRequestOptions()
options.isNetworkAccessAllowed = true
PHAssetResourceManager.default().requestData(
for: resource,
options: options,
dataReceivedHandler: { chunk in
imageData.append(chunk)
},
completionHandler: { error in
completion(error == nil ? imageData : nil)
}
)
}
Option C: Quick fix if using UIImagePickerController
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
// Use the original image URL, not the UIImage
if let url = info[.imageURL] as? URL,
let data = try? Data(contentsOf: url) {
// This data has EXIF intact
uploadImage(data: data, mimeType: "image/jpeg")
}
}
What NOT to do
// ❌ This strips ALL EXIF metadata
let image = info[.originalImage] as! UIImage
let data = image.jpegData(compressionQuality: 0.85)!
// ❌ This also strips EXIF
let uiImage = UIImage(data: originalData)!
let reEncoded = uiImage.jpegData(compressionQuality: 0.85)!
Upload API Reference
Upload to a Strava activity
POST /activities/{strava_id}/images
Content-Type: multipart/form-data
x-api-key: {api_key}
Form fields:
image: (file) — raw image bytes (JPEG, PNG, WebP, or HEIC)
image_type: "program" | "form" | "moment"
caption: (optional) text caption
Upload by date (non-Strava)
POST /activities/images/upload
Content-Type: multipart/form-data
x-api-key: {api_key}
Form fields:
image: (file) — raw image bytes
date: "YYYY-MM-DD"
image_type: "program" | "form" | "moment"
caption: (optional) text caption
Response (both endpoints)
{
"id": "69ca8e801eb4",
"strava_id": 17850497303,
"image_type": "moment",
"activity_date": "2026-03-25",
"caption": null,
"thumbnail_url": "/images/1/activities/69ca8e80_thumb.jpg",
"full_url": "/images/1/activities/69ca8e80.jpg",
"extracted_data": null,
"location": {
"lat": -33.7215,
"lng": 151.0842
},
"taken_at": "2026-03-25T06:15:00",
"width": 1536,
"height": 2048,
"file_size_bytes": 738487,
"created_at": "2026-03-27T03:05:57Z"
}
For program type images, extracted_data will contain:
{
"title": "Thursday Strength",
"exercises": [
{"name": "Back Squat", "sets": 5, "reps": 5, "weight": "80kg", "notes": null},
{"name": "Romanian Deadlift", "sets": 4, "reps": 8, "weight": "60kg", "notes": null}
],
"notes": "20 min cap",
"time_cap": "20 min"
}
List images for an activity
GET /activities/{strava_id}/images
x-api-key: {api_key}
Delete an image
DELETE /activities/{strava_id}/images/{image_id}
x-api-key: {api_key}
Backend Processing (for context)
The backend handles all of this automatically on upload:
- HEIC → JPEG conversion (if needed)
- Resize to 2048px max edge (full) + 400px thumbnail
- EXIF extraction: GPS coordinates + DateTimeOriginal
- For
programtype: Claude vision extracts workout program into structured JSON - Store everything in
activity_imagestable
No processing needed on the iOS side beyond sending the raw file bytes.
Constraints
- Max 5 images per activity (backend enforced)
- Max 10MB per image (before backend resize)
- Accepted: JPEG, PNG, WebP, HEIC
- HEIC is auto-converted server-side, so no need to convert on device
Testing
After implementing the fix:
- Take a photo with the iPhone camera (confirms EXIF is present)
- Upload it via the app to any activity
- Check
GET /activities/{strava_id}/imagesresponse - Verify
location.lat,location.lng, andtaken_atare populated - If they’re still null, the upload path is still going through UIImage somewhere