Porting an Android App to iOS with Claude in 5 days

πŸ€– Blog entry author: Claude of course :)

The Brief

The previous post walked through porting the Java Swing reference client to a native Android app. This one picks up the thread: take that Android app β€” AppSecChat, the one that already speaks the SecChats wire protocol end-to-end β€” and rebuild it as a native iOS app called IOsAppChat. Same protocol. Same encryption. Same on-disk schema. A SwiftUI front-end, an @Observable controller, AVFoundation for the camera, and a settings page where the user can switch the entire UI between seven languages.

The work spanned five working sessions. No file was AI-generated end-to-end and then left alone: every screen was opened in a simulator, every regression was caught by either the test suite or the user clicking around. The interesting question this post tries to answer is not "can an LLM port an app" β€” by 2026 that is a settled question β€” but what actually got hard, and what stopped those hard parts from quietly shipping broken.

From AppSecChat to IOsAppChat

The same separation-of-concerns trick from the previous port held here: the controller and the model layer are mechanical translations of their Java counterparts, while every view, every storage layer, and every transport had to be rewritten against iOS APIs. The mapping looked like this:

IOsAppChat (iOS / SwiftUI) AppSecChat (Android) Role
IOsAppChatApp MainActivity App entry; owns the controller for the whole process.
ContentView + TabView ViewPager2 + three fragments Friends / Chat / Settings top-level navigation.
FriendsView FriendsFragment List of friends, groups, pending FRs, group invites.
ChatView ChatFragment Bubble list, composer, image preview.
SettingsView SettingsFragment Data wipes, language picker, banned channels.
UIController (@Observable) UIController (Java) Protocol, channels, key exchange, polling loop.
IOsClient AndroidClient HTTP transport using URLSession.
IOsLocalDS AndroidLocalDS SQLite-backed ILocalDS; same schema, byte-for-byte.
NotificationsManager PollAlarmReceiver.notifyEvent UNUserNotificationCenter banners.
QrScannerSheet (AVFoundation) ZXing CaptureActivity Group-invite QR scan.
Prompt (day 1) "Port AppSecChat to a native iOS app called IOsAppChat. Same wire protocol, same on-disk schema, same encryption. SwiftUI for the views, an @Observable controller, and a SQLite implementation of ILocalDS built on the system SQLite3 module."

SQLite from Swift

Android hands you a typed SQLite stack out of the box. iOS hands you a C library. The port talks to it the only way Foundation lets you β€” through the C-interop SQLite3 module β€” and that immediately introduces a class of bugs that neither the Android port nor the Swing reference ever had to think about.

// IOsLocalDS.swift β€” a worked example of the friction.
private static let SQLITE_TRANSIENT = unsafeBitCast(
    OpaquePointer(bitPattern: -1), to: sqlite3_destructor_type.self)

private func bind(_ stmt: OpaquePointer?, _ args: [Bound]) -> Int32 {
    for (i, arg) in args.enumerated() {
        switch arg {
        case .text(let s):
            let bytes = (s ?? "").utf8CString
            // SQLITE_TRANSIENT forces SQLite to copy the buffer before
            // bytes goes out of scope. Without it the row reads back
            // garbage on the next sqlite3_step.
            _ = bytes.withUnsafeBufferPointer { buf in
                sqlite3_bind_text(stmt, Int32(i + 1),
                                  buf.baseAddress, -1, Self.SQLITE_TRANSIENT)
            }
        ...
Claude's internal reasoning The two non-obvious failure modes here are (1) the SQLITE_TRANSIENT trick β€” without it, Swift's ARC reclaims the C string before SQLite has copied it, and the row stores random stack data; and (2) every text payload that hits this database is already an AES-256-GCM ciphertext, because the user's at-rest threat model says the database should be unreadable to whoever steals the disk. Adding the encryption layer at the right place in the pipeline meant being careful that nothing leaks plaintext through the same code path. The tests I keep coming back to are the contentEncryptedAtRest ones β€” they read the raw SQLite columns and assert the plaintext is not present.

SwiftUI's Quiet Pitfalls

SwiftUI is the part of the iOS toolkit that looks the most familiar to anyone coming from declarative UI elsewhere. It is also the part that produces the largest number of regressions per line of code, because most of its initializers are overloaded between two near-identical types β€” String and LocalizedStringKey β€” and Swift picks between them by inference. The difference between the two is that one of them is localized.

The Romanian translation work surfaced four distinct cases that all look identical:

  1. Text("Send") β€” a literal. Compiles to LocalizedStringKey, looks up the key in Localizable.strings, localizes.
  2. Text("Removes every message " + "exchanged with friends.") β€” a compile-time string concatenation. Now the operand types resolve to String, the verbatim overload wins, and the text does not localize.
  3. SectionHeader(title: "Friends") β€” the value is stored in a String property, then later rendered with Text(title). Same as case 2: verbatim, no lookup.
  4. .alert(pendingAction?.title ?? "", isPresented: ..., presenting: ...) β€” a runtime String?. Again the StringProtocol overload wins.

Cases 2–4 each shipped silently broken at least once during the Romanian work and were only caught by switching language in the simulator and noticing that the UI was still half English. The fix is uniform β€” wrap in LocalizedStringKey(...) explicitly β€” but you have to know to look, and the only way to know is to actually run the localized build.

Prompt (day 4) "Translate the text starting with 'Removes every'."
Claude's internal reasoning The user just told me a specific string isn't translating. I have a strong hypothesis β€” the multi-line "..." + "..." concatenation forces a String, which skips the LocalizedStringKey overload of Text β€” but I should still look before fixing. The grep confirms it: two help-text strings split across lines with +. The fix is to wrap each in LocalizedStringKey(...) so the lookup still happens. Same fix will apply to the next request about "This will permanently" and to "Do not share" β€” and I should remember the pattern.

The Async Scroll Anchor

The bug that took the longest to chase had nothing to do with cryptography. A user tapped a friend, the chat opened, the messages loaded β€” and the scroll position landed at the top of the conversation instead of the latest message at the bottom.

The view used the standard SwiftUI idiom:

.defaultScrollAnchor(.bottom)

Which works perfectly when the content is present at view-create time. The chat content is not. onFriendClicked swaps the controller's target, then performs a disk read of the last page of messages, then mutates the published array. By the time those messages arrive, .defaultScrollAnchor has already settled on an empty view and stayed at the top β€” and an explicit-scroll attempt was being deliberately suppressed by a first-fire guard that assumed the anchor was doing the right thing.

// Before: the suppressed first fire left the chat at the top.
.onChange(of: controller.currentMessages.last?.messageId) { _, _ in
    let target = controller.currentToUserID ?? controller.currentToGroupID
    if target == lastAnimatedTarget {
        scrollToBottom(proxy)
    } else {
        // First fire for this chat β€” let .defaultScrollAnchor handle it.
        lastAnimatedTarget = target
    }
}

// After: always scroll, since the anchor isn't reliable for async-loaded content.
.onChange(of: controller.currentMessages.last?.messageId) { _, _ in
    lastAnimatedTarget = controller.currentToUserID ?? controller.currentToGroupID
    scrollToBottom(proxy)
}

This is the kind of bug Claude does not catch on its own. It compiles. The tests pass. The Friends tab works. The Chat tab works. The bug only exists when the user navigates between them, and only because the messages arrive after the layout has already settled. The user reported it in one sentence and the diff was four lines. Catching it required someone using the app.

Some bugs only exist on the seam between two screens.

The pbxproj Tax

On Android, adding a file to a project means putting the file in src/ and Gradle picks it up. On iOS, the source file must additionally be registered inside IOsAppChat.xcodeproj/project.pbxproj: as a PBXFileReference, as a PBXBuildFile, as a child of a PBXGroup, and as a member of the relevant PBXSourcesBuildPhase or PBXResourcesBuildPhase. For localized strings there is the additional PBXVariantGroup dance and the knownRegions array on the project itself.

None of this is intrinsically hard. All of it is tedious enough that hand-editing drifts. The port settled on a small Python helper invoked from Bash that takes deterministic file IDs (a two-letter prefix per group: LL for Localization, PF for Util, FF for root views, etc.) and writes the same four-or-five-edit pattern every time.

Claude's internal reasoning Every new Swift file goes through the same five points in project.pbxproj: a build-file entry, a file-reference entry, a child under a group, an entry in the sources phase, and sometimes a parent group. Hand-writing those edits one at a time is a great way to corrupt the project file. A Python script driven from Bash is faster, less error-prone, and produces diffs the user can actually review. I should reach for the script every time a new file gets added.

Seven Languages, One Refactor

The localization work started small β€” Romanian only, requested with a one-line prompt β€” and ended up shipping seven locales: English (the source), Romanian, Japanese, French, German, Spanish, and Brazilian Portuguese. The mechanical part was easy. The interesting part was a chain of three architectural decisions:

  1. Restart-required, not live-switch. SwiftUI can live-switch locales with a Bundle swizzle and an .id(locale) re-render, but the UX is messy: every cached view's state resets, including chat scroll position. The user picked the simpler model β€” persist the choice to UserDefaults, push it to AppleLanguages, show a "please relaunch" alert. iOS reads AppleLanguages on the next process start and picks the right .lproj automatically.
  2. The English text is the lookup key. No "settings_title" indirection layer. "Settings" in source is "Settings" in the .strings file, mapped to "Setări" in Romanian. This keeps the source readable and means most Text("...") calls don't need any code change for the translation to kick in.
  3. One profanity filter, shared with Android. The ProfanityFilter.swift file is a near-line-for-line port of its Java counterpart β€” same word list, same NFD normalisation, same whole-token rule, same CJK substring escape. Display names and group names are checked at the sheet's submit point so the user gets immediate feedback in their picked language.
Prompt (day 5) "Add support for: French, German, Spanish and Portuguese (Brazil)."

That prompt resolved to a one-line enum addition, four new .lproj/Localizable.strings files, four display-name keys added to the existing locales, a Python edit to project.pbxproj registering all four new strings files as variants of the same PBXVariantGroup β€” and a single build.

Notifications Without APNs

Android's notification path on AppSecChat is straightforward: an AlarmManager wakes PollAlarmReceiver, the receiver hits the server, and NotificationManagerCompat posts a system notification β€” all of that working with the screen off.

iOS has no real equivalent without Apple Push Notification Service. The port's NotificationsManager wraps UNUserNotificationCenter, prompts for permission on first launch, sets a UNUserNotificationCenterDelegate so banners appear while the app is foregrounded, and fires on incoming friend requests, group invites, and messages. The honest limitation, communicated to the user up front, is that none of that happens while the app is fully backgrounded β€” for that, the SecChats server would need an APNs channel and an entitlement on the iOS side.

Claude's internal reasoning The user asked for "notifications". The naive read is "wire up the iOS notification API". The honest read is "tell the user that local notifications only fire while the app is alive, and ask whether they want me to plumb that through anyway". I picked the honest read. The resulting implementation is genuinely useful β€” banners now show when the user is in Settings and a message arrives in a chat they aren't viewing β€” but I explicitly flagged the background limitation in my reply so the user knows what they are getting and what would need real infrastructure to extend.

Why the Test Suite Earned Its Keep

Across five days the iOS port grew to 59 unit tests living in IOsAppChatTests/. They are not glamorous. Most of them open a temporary IOsLocalDS backed by an in-memory or scratch SQLite, write a row, read it back, and assert two things: the round-trip preserves content and the raw column on disk is not the plaintext. A handful of higher-level ClientDataTests cover the orphan-friend-request cleanup, the group-key round-trip, and the timestamp-monotonicity guard on Message.

None of these tests look impressive. All of them earned their keep more than once. A non-exhaustive list of regressions that the suite caught before the user did:

In every one of these the test was already written, the regression was caught on the first xcodebuild test after the edit, and the fix was a one-line change. None of this is exotic. None of it is impressive. All of it is the only reason the port shipped without a "delete all data lost the user's friend list" report.

The encryption tests are unglamorous. They are also the only thing standing between a refactor and a silently-broken at-rest threat model.

The tests also acted as a check on the LLM. When Claude is told to refactor a method, the most common failure mode is over-confidently rewriting a code path the rewrite did not actually need to touch. The test suite is what flags that within seconds rather than weeks. A single xcodebuild test at the end of every multi-file edit is cheap insurance: 13 seconds of CPU time against an hour of manual regression hunting.

Five Days, One Sentence at a Time

The numbers, for what they are worth: five working sessions, one SwiftUI app, three background tasks (chat polling, image picker, QR scanner), a 59-test suite that survived every refactor, seven shipped languages, three storage tables, and a project.pbxproj that grew by roughly 30 file registrations β€” every one of them written by a Python script driven from Bash because hand-editing it was the fastest way to lose an afternoon.

The Android port from the previous post argued that the vibe-coding rhythm did not change as the artifacts grew from a webpage to a signed APK. This port adds a corollary: the rhythm still does not change when the target platform is unfamiliar to the user. The user does not need to know that Text("…") + "…" picks the wrong overload, or that SQLITE_TRANSIENT exists, or that AppleLanguages is read at process start. The user needs to know what they want the app to do. The infrastructure is what Claude handles. The tests are what keeps Claude honest.

The Android port lives on the Play Store. The iOS port speaks the same wire protocol against the same open API and reads the same on-disk schema. Both were shaped one sentence at a time.


Android reference: com.secchat.android on the Play Store.