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 EXIF
  • UIImagePickerController result → UIImage — strips EXIF
  • Any UIImageData conversion — 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:

  1. HEIC → JPEG conversion (if needed)
  2. Resize to 2048px max edge (full) + 400px thumbnail
  3. EXIF extraction: GPS coordinates + DateTimeOriginal
  4. For program type: Claude vision extracts workout program into structured JSON
  5. Store everything in activity_images table

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:

  1. Take a photo with the iPhone camera (confirms EXIF is present)
  2. Upload it via the app to any activity
  3. Check GET /activities/{strava_id}/images response
  4. Verify location.lat, location.lng, and taken_at are populated
  5. If they’re still null, the upload path is still going through UIImage somewhere