Iden Tumuhirwe
All writing

June 24, 2026 · 7 min read

The hardest part of a GPS camera wasn't the GPS

Kotlin · Android · Image Processing

This one's a private, work-facing project, so no repo link, but the shape of the problem is worth writing up on its own: a native Android app for field teams to take geotagged, timestamped photos with project and site context attached, the kind of thing you'd use for construction progress reports or infrastructure inspections where "this photo was taken here, at this time, on this project" needs to be provable, not just claimed.

Getting a GPS coordinate on Android is a solved problem. FusedLocationProviderClient, a LocationRequest, done. The part that actually ate the project was much dumber sounding and much harder in practice: stamping that coordinate, plus a timestamp, a site name, a project name, and current weather, onto the photo itself, as a watermark, in a way that's still readable no matter what the photo looks like underneath it.

Android doesn't have a watermark API

There's no built-in "burn this text onto this image" function. What you get is a Canvas and a Paint object, and everything above that, line wrapping, columns, spacing, shadows for contrast, is something you write yourself. It's a small typography engine, and photo watermarking is not a task that forgives a sloppy one.

The entry point copies the captured bitmap and draws straight onto the copy:

val result = originalBitmap.copy(originalBitmap.config ?: Bitmap.Config.ARGB_8888, true)
val canvas = Canvas(result)
drawOverlayGradient(ctx)
drawLogo(ctx)
drawContent(ctx)

drawContent is where the actual metadata lands: a timestamp header, then an adaptive information grid for location, weather, and project/site details, each section built out of section headers, primary text, and secondary detail lines, all positioned by hand.

Text doesn't wrap itself

Canvas.drawText draws exactly the string you give it, on exactly one line, off the edge of the image if it has to. A GPS coordinate is short and predictable. A location name from a reverse-geocode lookup, or a project name someone typed into a form, is neither. So the watermark needs its own word-wrap, measuring text width against the available column and breaking at word boundaries by hand:

for (word in words) {
    val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
    if (paint.measureText(testLine) <= maxWidth) {
        currentLine = testLine
    } else {
        currentY = drawTextLine(ctx, currentLine, x, currentY, paint, addSpacing = false)
        currentY += lineSpacing
        currentLine = word
    }
}

That function only exists because a long site name or address will otherwise run straight past the edge of the overlay and off the photo, unreadable, or straight through whatever's drawn next to it.

The layout has to work at any size, in either orientation

A photo from this app could be a tall portrait shot of a wall or a wide landscape shot of a site, at whatever resolution the phone's camera produces. The watermark can't be a fixed-size PNG stamped in the corner, it has to scale with the image and reflow with the aspect ratio, or it's either illegibly tiny on a 4000px photo or comically oversized on a smaller one.

The fix is a scale factor computed once per image, off the shorter dimension, that every measurement in the render pipeline multiplies against:

val scale = (minDim / 1080f).coerceIn(0.7f, 2.0f)
val padding = Design.SPACE_NORMAL * scale
val baseTextSize = ((minDim / 35f) * 1.1f).coerceIn(28f * 1.1f, 64f * 1.1f)

And the layout itself branches on shape, not just scale: portrait photos get a shorter overlay band across the bottom, landscape photos get a taller one, and wide-enough images get a two-column grid so location sits on one side and weather and project details sit on the other, instead of one long column of text creeping up the frame:

val overlayHeight = if (isPortrait) heightF * 0.25f else heightF * 0.33f
val useTwoColumns = ctx.contentWidth > 800

Getting the two-column math right, so the right column's start position always clears the left column's actual width instead of an assumed one, took a few passes. The first version used a fixed split and text in the left column would occasionally run into the right column's text on a narrower image.

Orientation has to be resolved before a single pixel gets drawn

Phone cameras don't always save pixels right-side-up, they save the image as the sensor saw it and record the correction in an EXIF orientation tag, and the OS rotates it for display. That's invisible until you draw directly onto the raw pixel buffer, at which point "invisible" becomes "your watermark is sideways relative to the photo everyone will actually see."

So loading the bitmap for watermarking means reading the EXIF orientation first and physically rotating the pixel data to match, before the canvas ever touches it:

val orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix()
when (orientation) {
    ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
    ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
    ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
    // ...flips and transposes too
}

Skip this step and the bug doesn't show up in the emulator, where orientation tends to behave, it shows up later on a specific phone held a specific way, which is a miserable class of bug to chase down after the fact.

A watermark can run a real photo out of memory

A modern phone camera can produce a bitmap tens of megabytes in size once decoded into ARGB_8888, four bytes per pixel, no compression. Watermarking needs a full mutable copy of that to draw on top of, doubling the memory pressure at exactly the moment the app is also holding the original in memory for the confirmation screen the user sees before saving.

So before it copies anything, it checks whether there's room:

val requiredMemory = originalBitmap.width * originalBitmap.height * 4L
val availableMemory = runtime.maxMemory() - (runtime.totalMemory() - runtime.freeMemory())
if (requiredMemory > availableMemory) {
    return originalBitmap
}

And loading itself downsamples anything above roughly 3000px on a side before it ever becomes a full bitmap, using the standard power-of-two inSampleSize trick, so a 12-megapixel capture doesn't get decoded at full resolution just to be scaled down cosmetically two steps later. Between the two, the failure mode on an old or memory-pressured device is a photo without a watermark, not a crash.

GPS was still its own smaller fight

Not the coordinate itself, the tradeoff around it. High-accuracy location on Android can take a real, user-noticeable amount of time to settle, and blocking a camera shutter on "wait for the best possible fix" makes the app feel broken. The location request asks for high accuracy but explicitly doesn't wait for it:

val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 15000L).apply {
    setMinUpdateDistanceMeters(5f)
    setWaitForAccurateLocation(false)
}.build()

and a fix request that's taking too long times out after fifteen seconds rather than hanging the capture flow indefinitely, falling back to whatever the last known location was if one's cached and still fresh. The watermark then draws the actual accuracy figure the device reported, in meters, next to the coordinates, rather than pretending every fix is equally trustworthy.

Where it's still imperfect

The word-wrap is a straightforward greedy line-break, no hyphenation, no shrink-to-fit if a single unbroken word is wider than the column, it'll just overflow that one word. The two-column threshold is a fixed content-width number, not something that adapts to font metrics directly, so an unusually large system font setting could, in principle, need more room than the breakpoint assumes. And the memory guard's answer to "not enough room" is to skip the watermark silently rather than surface anything to the user, which is the right failure mode for a camera app that should never crash, but it does mean a photo could occasionally save without its metadata stamp on an especially constrained device, and nothing in the UI would tell you that happened.