TIL: .environment(\.locale) does not select a strings table — how my in-app language picker silently never worked (and fixing it cut my build time by 75%)

Sharing a failure mode that survived in my codebase for months, because the app looked localized the whole time.

Setup: my app has an in-app language picker (independent of the iOS system language), implemented via a language manager that resolves strings from the selected .lproj bundle. Standard approach.

The bug: the overwhelming majority of my UI strings were Text("some.key") — i.e., SwiftUI LocalizedStringKey literals. Those resolve against Bundle.main using the system locale machinery. Setting .environment(\.locale) changes formatting behavior, but it does not select which strings table your keys resolve from. Result: 225 of 269 routed call sites silently bypassed the language override entirely. The picker "worked" for the ~44 call sites that went through the manager, which is exactly why nobody noticed — the app changed some strings on switch and looked plausible in both languages.

Confirmed on-device with diagnostic logging: the resolved bundle for LocalizedStringKey paths was Bundle.main, regardless of the override.

The fix: a tiny helper — L.t(_: String.LocalizationValue) -> String — that resolves through the override bundle (String(localized:bundle:)), and mechanically routing every UI string through it. An enforcement script in lint now flags any bare string in Text/Button/Label/.accessibilityLabel.

The completely unexpected side effect: build times collapsed. Bare LocalizedStringKey literals create expensive type-checker constraint problems (Text("...") has to disambiguate between StringProtocol and LocalizedStringKey overloads at every call site). Replacing them with a concrete String return eliminated that: one heavy view body went from type-checking as my slowest file to ~98% faster, and the whole target build time dropped ~75%. I did not see that coming from a localization refactor.

Two smaller traps from the same audit:

  • String(format:) with catalog plural variations silently returns the raw %#@token@ if you forget to pass locale:.
  • Xcode's string extractor can't see keys behind a helper function, so the catalog stops auto-populating — new keys become a manual (and CI-checked) step. Worth knowing before you commit to the pattern.

Curious if others route in-app language switching differently — is there a cleaner first-party way to point LocalizedStringKey resolution at a non-main bundle that I missed?

reddit.com
u/KREANIQS — 3 days ago
▲ 0 r/iOSProgramming+1 crossposts

Lessons from shipping a production app on SpeechTranscriber + on-device Foundation Models — including an OS bug that permanently eats locale slots

I just shipped my first app built end-to-end on Apple's on-device AI stack — SpeechAnalyzer/SpeechTranscriber for transcription and Foundation Models for enrichment (it's a voice-notes app; every recording gets an on-device title/summary/tags/tasks). Some things I learned the hard way that I haven't seen written up much:

1. The simulator will lie to you — twice.

The simulator cannot transcribe at all, and the simulator's language model is not the on-device model. Output quality, instruction-following, and hallucination behavior differ meaningfully. I now treat real-device validation as a hard gate for any prompt/template change — my test corpus includes Swiss-accented German dictation because that's where the on-device model diverges most from the "clean" results the simulator suggested.

2. SpeechTranscriber locale reservations: a system-wide cap of 5, and (currently) no way back.

This one cost me an architecture. On-device transcription locales are backed by downloadable assets, and the system caps reserved locales at 5 — system-wide, not per app. In my testing on current iOS releases:

  • The reservation is taken by the asset install and survives reboot AND app reinstall.
  • AssetInventory.release(reservedLocale:) appears to be a no-op — I never got a slot back.
  • An explicit reserve(locale:) at the cap can hang (reproducibly under the Xcode debugger in my setup).

I originally built an LRU "reservation manager" that released the least-recently-used locale before installing a new one. Since release doesn't release, that design was dead on arrival. What shipped instead: a proactive budget gate that reads reservedLocales before any OS call, installs strictly lazily (never speculatively — no warm-up, no on-selection prefetch, because every install permanently spends a slot), and surfaces a clear "language budget exhausted" state to the user instead of ever hitting the cap inside an OS call. Feedback filed with Apple.

3. One fresh LanguageModelSession per invocation.

Reusing sessions across notes led to context bleed between unrelated inputs. One session per call is now a hard rule for me, enforced by tests.

4. Prompt-injection resistance for user-content prompts.

Voice transcripts are untrusted input into the enrichment prompt. Delimiter-wrapping the transcript made instruction-following robust; and I removed all literal examples from the prompt after seeing example fragments leak into generated output on device (again: not reproducible in the simulator).

5. Pass the language explicitly, always.

Auto-detection of the recording language was unreliable enough that I now pass the language explicitly into both the model instructions and the prompt. Related fun fact from testing: Apple appears to use one shared German model across all de-* locales, so switching de-DE/de-CH/de-AT changes nothing about transcription quality.

6. Crash-safe audio: don't record straight to AAC.

A killed mid-recording AAC/m4a is an empty husk. I record LPCM into CAF and encode to AAC at ingest — recordings now survive calls, interruptions, and force-quits, and a salvage pass recovers anything interrupted.

Happy to go deeper on any of these.

The app is Vocapa (https://apps.apple.com/app/id6789586072) but the point of this post is the stack — curious whether others have seen the locale-reservation behavior, and whether anyone found a way to actually free a slot.

u/KREANIQS — 3 days ago
▲ 1 r/iosdev+1 crossposts

I just shipped version 1.4 of my radio streaming app, and it was the first release where I had to push a major update through App Review at the same time as a brand-new paid subscription (Monthly / Annual / Lifetime) through IAP Review. I’d read a lot of conflicting advice on how Apple actually handles this in parallel, so I want to share what I observed.

This is not a marketing post. I’ll keep app details minimal at the bottom for context, and the link is there only if anyone wants to look at the actual paywall structure.

The setup

  • Solo developer, native SwiftUI app, multi-platform (iOS, iPadOS, macOS, tvOS, watchOS, CarPlay).
  • Previous releases were free-only. 1.4 introduced a single “Premium” tier with three SKUs (monthly, annual, lifetime) using StoreKit 2.
  • All v1.3 features stay free forever — the paid tier only gates a subset of new 1.4 features (EQ, in-app volume control, sleep timer presets, station alarm, watchOS app, tvOS app).
  • Widgets stayed free on purpose. They’re system integrations — paywalling them felt wrong.
  • Build submitted with all paid features behind a runtime entitlement check, with a debug toggle for review.

What “parallel review” actually means in practice

When you submit a build that introduces a new IAP, App Review and IAP Review are not actually decoupled in the way the docs imply. Two things happen:

  1. The build goes into App Review like any other binary.
  2. Each IAP product in “Ready to Submit” state attaches itself to the next submitted build and gets reviewed alongside it.

If either side is rejected, the whole submission stalls. You don’t get a partial pass where the app ships and the IAP gets reviewed later — not on a first-time IAP submission.

A few things I had to get right before the review queue:

  • Screenshots for each IAP, not just the app. Easy to forget when you’re focused on App Store screenshots.
  • Review notes that explicitly walk through the paywall flow, including how to trigger it, what’s gated, and — critically — what stays free. I added a one-paragraph “this is the value split” note up top.
  • A debug build path to free ↔ premium toggling for the reviewer. I left this in #if DEBUG and called it out in review notes. This saved at least one rejection cycle.
  • Sandbox account ready and explicitly mentioned, even though Apple has its own.

Things that almost tripped me up

  • Free features moved behind premium = guideline 3.1.2 risk. I’d read horror stories about apps adding paid tiers and getting flagged for taking previously free functionality away from users. I dealt with this by being explicit in review notes and on the App Store listing: “All v1.3 features remain free forever.” No issue — but I think the explicit framing helped.
  • Subscription metadata localization. Each subscription needs its display name and description per locale, and they’re reviewed. I support 29 languages in-app, but for the IAP metadata I went with English + a small set of strategic locales for now to keep the surface manageable.
  • “Restore Purchases” button. Required, and reviewers do test it. Make sure it works without an active subscription too — it should silently no-op, not show an error.
  • StoreKit 2 transaction listener. Has to be running before the app’s main UI appears, otherwise renewed entitlements may not be reflected on cold launch. I put it inside an init() on the entry point.
  • Family Sharing flag. You set it per product, and it can’t be changed after the first review without a re-review. Decide deliberately.

What surprised me

  • Review time was normal. I expected the IAP layer to slow things down. It didn’t — review came back in roughly the same window as a build-only submission.
  • The reviewer hit the paywall. I could see in my analytics (after release) that the review-flagged installs triggered the paywall flow, used the debug toggle, then exited cleanly. So the review notes worked — they actually followed them.
  • TestFlight + sandbox is unreliable for cross-device entitlement sync. On watchOS in particular, StoreKit 2 sometimes fails to surface the active subscription until well after install. I ended up adding an iPhone-side fallback: the phone reports premium status to the watch via WatchConnectivity, and the watch trusts that flag if its own StoreKit query hasn’t resolved. Worth knowing if you’re shipping a companion watch app behind a paywall.
  • iCloud KVS is the right place for premium-derived state. Not the entitlement itself — StoreKit owns that — but anything the user customizes inside premium features (EQ presets, volume, sleep timer presets, alarms). Means an upgrade on one device immediately makes a user’s existing customizations available on the others.

What I’d do differently next time

  • Submit IAPs to review before the build that contains them. You can submit IAP metadata for review independently in App Store Connect; not every team realizes this. It de-risks the build review.
  • Cut localization on IAP metadata for the first launch. I tried to do all 29 languages and it was the single biggest source of last-minute work. You can add locales later without re-reviewing the IAP itself.
  • Have a clear “hidden” mode for premium UI. I added a premiumFeaturesHidden toggle so users who don’t want paid features can hide them entirely, not just see a paywall. This wasn’t required by review, but it cuts down on the “why is this app pushing me to pay” feedback you get from the small fraction of users who really don’t want a paid tier in their face.

Open question for the sub

For anyone who’s done this more than once: do you keep IAP metadata in source control somehow, or accept that App Store Connect is the source of truth? I found myself wishing for a Fastlane-style flow for IAP descriptions across 29 locales, and I’m not sure if I’m missing an existing tool.

Context for anyone curious: the app is Pladio, a multi-platform radio streaming app. Listing here only because someone will ask: https://apps.apple.com/ch/app/pladio-my-radio/id6747711658. Happy to answer specific implementation questions in comments — paywall, StoreKit 2 wiring, watchOS entitlement fallback, whatever’s useful.

u/KREANIQS — 4 months ago
▲ 13 r/radio

Hi everyone,

I’m a solo developer based in Switzerland, and I just released Pladio 1.4, a major update to my multi-platform internet radio app. I built it because the existing radio apps either felt dated, were riddled with ads, or didn’t take privacy seriously. Pladio runs natively on iPhone, iPad, Mac, Apple Watch, Apple TV and CarPlay — all from a single codebase.

What’s in 1.4

Free for everyone (and staying free, forever):

  • Worldwide station discovery with a curated, regularly updated database
  • Live song recognition (uses stream metadata first, with audio fingerprinting as a fallback)
  • Apple Music integration — add identified songs to your library, see lyrics
  • iCloud sync for favorites, recents, manual stations, theme, and display preferences
  • Home Screen and Lock Screen widgets (Now Playing, Favorites, Recents, My Stations, Quick Resume)
  • Siri Shortcuts and App Intents
  • Spotlight search and Focus filters
  • Native CarPlay support
  • 29 languages with runtime switching
  • macOS menu bar mini player
  • AirPlay

New in 1.4 — Premium (single tier, monthly / annual / lifetime):

  • 5-band audio equalizer with 8 factory presets, custom presets, and per-station memory
  • In-app volume control with iCloud-synced level and mute state
  • Sleep timer presets with auto-activate on play and quick-extend from the Lock Screen
  • Station alarm — wake up to a radio station, with gradual fade-in, snooze, and a fallback sound if the stream fails
  • Apple Watch companion app (remote control, sleep timer, station library)
  • Apple TV app with Top Shelf, Now Playing, EQ, and Siri Remote support

What I tried to do differently

  • No ads, no tracking pixels. Privacy-respecting analytics only, hosted in the EU.
  • Free features stay free. The paid tier only gates new 1.4 features. If you only used Pladio for free before, nothing you had is locked behind a paywall now.
  • Single subscription tier. No “Plus / Pro / Max” gauntlet. One price, growing value over time — new premium features get added without a price bump.
  • Widgets are free. They’re system-level integrations and I think paywalling them is the wrong call.
  • Single codebase. SwiftUI + a small custom audio engine. The watchOS, tvOS and macOS versions are not afterthoughts — each has UI tuned for the platform.

Where to find it

Happy to answer questions — about the app, the feature set, the platform-specific quirks (CarPlay templates, watchOS connectivity, EQ implementation), or how I think about pricing as a solo dev. Feedback welcome, including the unkind kind.

u/KREANIQS — 4 months ago