---
title: "Launching Bronzla"
description: "Bronzla is a free, open source iOS and watchOS app that tells you how long you can safely stay in the sun. The exposure model, the App Review rejection that made it better, and what is still missing at 1.0."
date: 2026-08-06
category: Engineering
readingTime: "6 min read"
---


Bronzla is live. It is a free iOS and watchOS app that tells you how long you can safely stay in the sun, based on your skin type and the live UV index. No account, no ads, no subscription, no in-app purchases, no analytics SDKs. The source is public under Apache 2.0 at [github.com/umutguden/bronzla](https://github.com/umutguden/bronzla), and the app is on the [App Store](https://apps.apple.com/app/id6793729675).

It went out at 05:00 GMT this morning, which is 08:00 in Istanbul, which felt like the right hour for a sun app in a country where the beach season runs from May to October.

## The problem it solves

Nobody knows how long they can stay in the sun. People know their rough type, pale and burns, olive and tans, and they know that today feels hot, and those two facts are not the same as an answer. Heat is not UV. A cloudy day in July at altitude will burn you faster than a bright day in October at sea level, and there is nothing in the felt experience of standing outside that tells you which one you are in.

The UV index exists precisely because that gap is real, and yet almost nobody translates it into the only number that matters to a person on a beach: minutes. Bronzla does that translation, and it is honest about how rough the translation is.

## The exposure model

The maths sits in one pure Swift enum with no dependencies, `ExposureCalculator`, and it is the only part of the codebase I would call load-bearing in a safety sense.

It takes your Fitzpatrick phototype, the current UV index from WeatherKit, and your sunscreen SPF if you are wearing any. The UV index converts to erythemally weighted irradiance using the WHO and WMO definition, where one unit of UV index is 25 mW/m². That divides into the minimal erythemal dose for your phototype, a population-average figure in J/m² taken from the clinical literature, and what comes out is a time to erythema. Then it takes 40% off. The safe session is 0.6 of the calculated burn threshold, because a model built on population averages should not hand you a number that sits exactly on the edge of the thing it is trying to prevent.

For a session that runs across the afternoon it integrates over the hourly forecast rather than assuming a fixed UV index, because the peak is what burns you and the peak moves.

Every constant traces to a published source. That was true from the first commit, and it turned out not to be enough.

## The rejection that improved the app

Build 10 was rejected under App Store guideline 1.4.1, Safety and Physical Harm. The reason was fair and I did not enjoy reading it: the app gave exposure times derived from medical literature, and the citations for that literature existed only in code comments. A user standing in the sun holding a phone that says "you have 34 minutes" had no way to ask why, and no way to check.

That is a real objection, not a bureaucratic one. If you are going to put a number in front of someone that they might act on with their skin, they are entitled to reach the source of that number from inside the app, not from a GitHub repository they will never open.

So build 12 added `MedicalSourcesView`, a citations sheet reachable from the medical disclaimer on every one of the thirteen screens that gives guidance, plus a route into it from Settings. The rule in the codebase now is that a cited constant must exist twice: once in the calculator and once in `MedicalSource.swift`, where a user can actually get to it. A citation that only a compiler can read is not a citation.

Two other things went in on the same pass, both found because the review forced a proper audit rather than a patch. The recommended SPF for phototypes V and VI moved from 20 to 30, matching the American Academy of Dermatology guidance that was already cited. And several vitamin D figures that read like clinical results were rewritten to say plainly that they are the app's own estimates, because they are.

Build 12 was accepted yesterday. The version that shipped is better than the version that was rejected, and it is better in exactly the dimension I would have claimed to care about most. That is worth sitting with.

## Building for the absence of a service

The architectural decision I would defend hardest is that every external dependency sits behind a protocol with both a live and a sample implementation, injected through the SwiftUI environment. `UVDataProviding` is the one that matters.

This is not testing purism. WeatherKit is unavailable in three situations that all occur constantly. It returns nothing in Xcode previews. It returns nothing offline, which is to say on a beach, which is to say in the app's actual habitat. And it is metered at 500,000 calls a month, so every preview render and every test run that hits the live provider is spending a budget on nothing.

The sample provider solves all three at once. It also means the repository is usable by anyone who clones it without an Apple Developer team and a WeatherKit-enabled App ID, which is most people, which matters now that the source is public. Caching is an actor holding up to eight reports and discarding anything older than twelve hours.

The rest is unremarkable in the good way. SwiftUI only, iOS 18 minimum, Swift 6 with complete strict concurrency so that every type crossing an isolation boundary is `Sendable` by the compiler's judgement rather than mine. SwiftData for the profile and session models. Swift Charts for the forecasts. A standalone watch app, home screen widgets, and a Live Activity for the running timer. 129 unit tests across 14 suites in Swift Testing.

## Privacy, in specifics

Location never leaves the device. Before a coordinate is used as a cache key it is rounded to two decimal places, roughly a kilometre, which is enough precision to cache a UV forecast for where you are and not enough to identify where you live. Sign in with Apple is optional and gates one thing only, the global leaderboard. Session tokens live in the Keychain, scoped to the device, never in `UserDefaults`. Error logging splits the domain and code, which are public and carry the useful failure information, from freetext, which is private and gets redacted out of a sysdiagnose.

Those are the specifics because vague privacy claims are worth nothing. Anyone can read the code and check every sentence in that paragraph.

## What is not finished

The backend is an AWS CDK stack that is written and unit-tested and not deployed. The global leaderboard tab therefore renders and does nothing in the shipped build. The on-device family leaderboard, which is the one people will actually use, works fully offline.

Seven UI tests around the onboarding wizard fail, and were failing before submission. `TimerView` shows a UV index without the WeatherKit attribution view that belongs beside it, which is a compliance gap and a 1.0.1 fix. One citation in `SkinType.swift` has no matching in-app source entry, which is the same class of problem the 1.4.1 rejection was about, caught after the binary was locked.

All of that is in the README and in `CLAUDE.md`, in public, because a repository that implies a working service it does not have is worse than one that admits the gap. Launch day is the most tempting possible moment to round the description up, and rounding up is how you end up with software nobody can trust the documentation of.

## Why it is free, and why it is open

It is a utility that took a few months and can be maintained by one person. Charging for it would have meant a purchase flow, a receipt validator, a support burden and a reason to care about conversion, all bolted onto an app whose entire job is to tell you to go back inside sooner than you wanted to. The incentives point the wrong way. An app that makes money from time in the sun should not be the one telling you when to leave.

Open source follows from the same place. The exposure model is the app, and a safety model you cannot inspect is a claim rather than a model. The licence is Apache 2.0, with the Bronzla name and icon held back as trademarks, which is the same shape I have [landed on before](/blog/open-source-licensing-lessons): take the code, ship it, do not ship it as this.

If you fork it and change `ExposureCalculator`, the contributing guide asks for a cited source and a test. That is the whole review bar, and it exists because that file is the one that can burn someone.
