A phone-as-camera app has two user interfaces, and almost every article ever written about one of them ignores the other.
The first is the Android app on the camera phone. It has layouts, string resources, a manifest. Android has spent a decade building machinery to translate and mirror it, and that machinery is genuinely good.
The second is the page the phone serves over your LAN when you open its address in a browser. It is HTML, it comes off an embedded web server running on the handset, and it inherits none of that machinery.
Here is the part that should bother you: on a device recording with the screen off, the first interface is the one nobody is looking at. The second is the only one anybody reads.
That asymmetry is a real architectural seam, and it gets wider the further your users are from the language you wrote in. This is a description of a problem shared by every app that serves a local web UI — ours included — not a bug report about any one of them.
What Android actually gives the app UI
Worth being concrete, because the contrast is the whole point.
Resource qualifiers. You add directories under res/ named <resource type>-b+<language code>[+<country code>]. Android's docs give values-b+es/ for Spanish strings and mipmap-b+es+ES/ for icons in the es-ES locale. Then: "Android loads the appropriate resources according to the locale settings of the device at runtime." You write the strings; the lookup is free.
Resolution that degrades sensibly. This was not always true. Before Android 7.0, if your app had es_ES strings and the device was set to es_MX, the system stripped the country code, failed, and fell back to your default. The docs describe the old behaviour with unusual candour: the system "displays English strings without knowing whether the user can understand English. This behavior is pretty common today."
Android 7.0 (API level 24) fixed it by searching children of a language. A device set to fr_CH with no fr resources now resolves fr_CH → fr → children of fr → fr_FR. Android 7.0 also let users choose multiple locales, so an app with no French at all can still fall through to the user's second-preferred language rather than to your default.
Per-app language. Android 13 added a centralized place in system settings where a user picks a language for one app, independent of the system language — for the multilingual user whose phone is in English but who wants this app in Hindi. You opt in with android:localeConfig pointing at res/xml/locale_config.xml. The docs are explicit about what silence means: "Omitting the android:localeConfig manifest entry signals that users shouldn't be able to set your app's language independent of their system language within their system settings." At runtime it is LocaleManager.setApplicationLocales / getApplicationLocales, with AndroidX equivalents for older versions.
RTL mirroring. Set android:supportsRtl="true", target API 17+, and convert left/right to start/end in your layouts — which, per the docs, "lets the framework align your app's UI elements based on the user's language settings." As of Android 4.2 (API level 17) most framework UI elements handle RTL text direction automatically. There are -ldrtl and -ldltr resource qualifiers if you need direction-specific resources.
Four systems. Declarative, well documented, and they cost you almost nothing at runtime.
What the served page inherits from all of that
Nothing.
Not "a reduced version." Not "the defaults." Your embedded server hands a browser a byte stream. The browser has never heard of your locale_config.xml. It cannot see values-b+bn/. supportsRtl is an attribute in an Android manifest and HTML does not read Android manifests.
And critically: the two ends are usually different devices. The camera phone is the old handset — often a hand-me-down, sometimes configured by whoever owned it before. The viewer is on their own phone or a laptop. There is no reason those two devices agree about language, and no mechanism by which the camera phone's setting could reach the viewer's browser even if they did.
So the locale that Android worked so hard to resolve correctly describes a screen that is deliberately off, on a device sitting face-down on a shelf.
The only signal that crosses the gap
There is exactly one, and it comes from the other direction: the Accept-Language request header.
Browsers set it from their active UI language, and users can add more preferred languages in browser settings. It carries a list ordered by a quality weight — en-US,en;q=0.9,bn;q=0.8 — where the weight defaults to 1. Language tags are a 2–3 letter base tag plus optional subtags, which may be a region (en-US) or a script (sr-Latn).
If you are on Ktor — as we are — the server-side API is already there. ApplicationRequest.acceptLanguage gives you the raw header value; acceptLanguageItems() returns the languages already sorted by quality, so you are not parsing q-values by hand. Both sit alongside the other convenience accessors like acceptEncoding() and contentType(), reachable from call.request in a route handler.
The honest framing is that this is a hint, not an instruction. It tells you what the person holding the viewing device has configured their browser to prefer. That is a far better guess than your source language, and a far worse guess than an explicit choice. Which is why the answer is both: negotiate a default from Accept-Language, then put a visible language control on the page and remember the choice. The header picks the first render; the user picks every one after that.
One more thing worth knowing: servers may return 406 Not Acceptable when they cannot match a requested language, but in practice this is rarely done — servers normally serve the closest thing they have. For a camera page, serving something is unambiguously correct. Nobody checking whether their shop's shutter is closed wants an HTTP error because their browser asked for a language you do not ship.
Direction is not language, and this is where it breaks
This is the trap I would most expect a careful developer to walk into.
MDN is blunt about it: the lang attribute declares the language but does not imply the base direction. You cannot rely on lang alone. You must set dir explicitly. If no element sets a direction, the default is left-to-right — which, for a page you have just correctly detected as Arabic, Hebrew, Persian, or Urdu, is simply wrong. The recommendation is to set dir on the root element.
So <html lang="ar"> alone gets you Arabic strings in a left-to-right layout. You need <html lang="ar" dir="rtl">.
And note what you lose by leaving the Android world: start and end were doing real work in those layouts. In CSS you have logical properties available, but if your page is built on margin-left, padding-right, and float: left, then flipping dir moves the text and leaves the furniture behind. A live-view page is mostly chrome around a video element — buttons, a status line, a timestamp — and chrome is exactly what mirrors.
There is also dir="auto", where the user agent infers direction from the first character with strong directionality. It is genuinely useful for one specific thing: a field containing text you did not author and cannot predict, like a user-supplied camera name. It is the wrong tool for the page shell, which you do know the direction of.
Bidirectional text is not an RTL-only concern, either. Android's own guidance makes the point that numbers and embedded LTR text run left-to-right inside otherwise-RTL text, and that LTR languages including English are also bidirectional because they can contain embedded RTL runs. A camera page is full of exactly this: timestamps, resolutions, IP addresses, and file sizes embedded in translated sentences.
Digits are not a cosmetic detail
Here is the fact from Android's docs that reframed this for me. Android 7.0 and higher supports 27 Arabic locales. They can share most resources — "but some prefer ASCII digits, while others prefer native digits."
Twenty-seven. For one language. Differing on something as basic as which glyphs a number is written in.
The lesson Android draws is the one to carry across to the web side: use formatters rather than hard-coded numbers and date strings, because format differences can be significant even inside a single language. On a camera page, nearly everything that updates is a formatted value — clock time, a date, a duration, a byte count. Those are the strings most likely to be concatenated by hand in a template, and the least likely to be reviewed by anyone who reads the target script.
Why this lands hardest on the install base
Our users are concentrated in India, Bangladesh, Iraq, and Algeria. Hindi, Bengali, and Arabic — and Arabic is right-to-left.
Now picture how a phone camera is actually used in a shared house or a small shop. One person sets it up. That person may be comfortable in English. The people who then use it — the family member checking the shop counter at midday, the relative watching a doorway — open a URL on their own phone and read whatever comes back. They never touch the camera phone. They never see the Android UI. The served page is the entire product to them.
Which means the localization that matters for adoption is the half that Android does not do for you.
There is a pleasant consequence, too. Because this page is served from the phone over your own network, the language negotiation happens entirely on the LAN. No cloud service is deciding what language you get, no account setting is remembered on someone else's server, and nothing about your household's language preferences leaves the building. The same architecture that keeps the video local keeps this local as well.
A short checklist
If you are shipping an embedded web UI on Android, in rough order of payoff:
-
Read
Accept-Languageand pick a default from it. On Ktor,acceptLanguageItems()gives you the sorted list. -
Set
langanddiron the root element.langdoes not imply direction. Ever. - Add a visible language control and remember the choice. The header is a hint about the browser, not a statement from the person.
-
Audit the CSS for physical properties.
margin-leftandfloatdo not mirror. Logical properties do. - Format numbers and dates with a locale-aware formatter, never by concatenation.
-
Use
dir="auto"only for untrusted strings — a user-entered camera name — never for the shell. - Test the page in an RTL locale on a second device, not on the camera phone. The camera phone's locale is not the one being exercised.
Point 7 is the whole article compressed. The screen you would naturally test on is the one that is switched off.
Related reading:
- How an Android Phone Serves Its Own Live Camera Feed Over Your LAN: An Embedded Ktor Server Deep Dive — the server this page is served from.
- Can Two People Watch Your Old-Phone Camera at the Same Time? — when more than one person reads that page, which is when its language starts to matter.
- How to Watch Your Shop With an Old Android Phone — No Internet Plan, No Subscription, No Credit Card — the setup this scenario is drawn from.
Background Camera RemoteStream records with the screen off and serves a live view from an embedded server on the phone itself, with no cloud account: Google Play · superfunicular.com
Sources: Language and locale resolution · Per-app language preferences · Support different languages and cultures · MDN: Accept-Language · MDN: dir · Ktor: acceptLanguageItems
Top comments (0)