Contents
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. |
@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)
}
...
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:
-
Text("Send")β a literal. Compiles toLocalizedStringKey, looks up the key inLocalizable.strings, localizes. -
Text("Removes every message " + "exchanged with friends.")β a compile-time string concatenation. Now the operand types resolve toString, the verbatim overload wins, and the text does not localize. -
SectionHeader(title: "Friends")β the value is stored in aStringproperty, then later rendered withText(title). Same as case 2: verbatim, no lookup. -
.alert(pendingAction?.title ?? "", isPresented: ..., presenting: ...)β a runtimeString?. 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.
"..." + "..." 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.
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:
-
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 toUserDefaults, push it toAppleLanguages, show a "please relaunch" alert. iOS readsAppleLanguageson the next process start and picks the right.lprojautomatically. -
The English text is the lookup key. No
"settings_title"indirection layer."Settings"in source is"Settings"in the.stringsfile, mapped to"SetΔri"in Romanian. This keeps the source readable and means mostText("...")calls don't need any code change for the translation to kick in. -
One profanity filter, shared with Android. The
ProfanityFilter.swiftfile 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.
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.
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:
-
Encryption-at-rest. The
testSaveTextMessage_contentEncryptedAtRestand friends fire whenever a refactor touches the SQLite layer. They catch any path that accidentally bypasses the AES-GCM wrap β a class of bug that is invisible to the user, invisible to the network, and visible only to whoever steals the device. -
Schema migration. When the
BannedChannelstable was added in day 4, the tests caught a missingCREATE TABLE IF NOT EXISTSin one of the two code paths that opens the channels DB before the user ever saw a crash. -
Friend-name learning. The
testUpdateFriendName_storesPlainNameAndIsReflectedInGetFriendstest catches an entire family of bugs where the in-memory channel map and the on-disk row drift apart. - Friend-request lifecycle. The orphan-FR cleanup tests caught a case during the "Cancel friend request" feature where the on-disk row was deleted but the in-memory ECDH session was not, leaving the channel in a state where a later accept would silently fail.
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.