More Vibe Coding with Claude

🤖 Blog entry author: Claude of course :)

The Brief

The previous post documented building the interactive API demo from a blank file in a single sitting. This one is a step up in scope: take the existing Java Swing reference client and port it into a native Android app — same protocol, same end-to-end encryption, same controller logic — but with a tabbed Material UI, lifecycle-aware fragments, persistent SQLite storage, and background polling that survives the screen turning off.

The goal was not to throw away the desktop implementation. It was to respect it: share as much code as possible with the Swing client, isolate platform-specific concerns, and ship a great product for the Android users.

The Reference: ChatJavaUI

The starting point is an open-source Java Swing client living at github.com/Secchats-tech/ChatJavaUI. Its source tree is small and disciplined:

The clean separation between ui/ and control/ is the single most important property of this codebase. Everything below the controller is platform-agnostic: JSON over HTTP, channel state, cryptographic key derivation, message encoding. None of it knows about Swing. None of it would need to know about Android.

Prompt "Port ChatJavaUI to a native Android app called AppSecChat. Keep the controller and the client/data layer as close to the desktop version as possible. Use Java (not Kotlin), Material 3, and the same package layout where it makes sense."
Claude's internal reasoning The desktop split — com.secchat.ui for views, com.secchat.control for behaviour — is the right axis to preserve. I'll mirror it on Android as com.secchat.android for the platform-specific layer and reuse the existing com.secchat.control.UIController contract. The only places I should write Android-specific code are: (1) the views, which must become Fragments hosted by a ViewPager-driven Activity; (2) the network client, since HttpURLConnection must run off the main thread; (3) the local store, which becomes a SQLite implementation of ILocalDS; (4) background polling, which needs to use AlarmManager rather than a Swing Timer. Everything else stays.

Architectural Mapping

Before writing a single line, Claude proposed a one-to-one mapping between the two trees. The result is the table below — the Android source on the left, the Swing source it derives from on the right.

AppSecChat (Android) ChatJavaUI (Swing reference) Role
MainActivity JClientMain Bootstraps the controller, hosts the view container.
MainPagerAdapter + tabs ChatUI (split pane) Top-level layout. Mobile prefers tabs over a split pane.
FriendsFragment UsersView List of friends and groups, add/remove actions.
ChatFragment ChatView Message stream, composer, attachments.
SettingsFragment DebugPanel Server URL, logs, diagnostic actions.
control.UIController control.UIController Protocol, channels, keys — almost line-for-line identical.
net.AndroidClient JDK HttpClient in desktop Off-main-thread HTTP using HttpURLConnection.
data.AndroidLocalDS LocalSQLiteDS (shared lib) SQLite-backed ILocalDS implementation.
PollAlarmReceiver javax.swing.Timer Wakes the app periodically to fetch new messages.

The Controller Stays

The single biggest win of the port is that UIController moves over with almost no edits. It implements the same ChatListener interface as on desktop, holds the same channel registry, performs the same key-exchange handshake, and emits the same events. The desktop and Android builds both depend on the same shared_client.jar, so the protocol implementation is literally byte-for-byte identical.

Prompt "Reuse shared_client.jar as a Gradle flat-file dependency. Don't reimplement the protocol classes."

What changed in the Android UIController was only the surface: callbacks dispatch via Handler(Looper.getMainLooper()) so UI updates always land on the main thread, and the friends/messages listeners are exposed through small interfaces that fragments subscribe to in onResume() and tear down in onPause().

Views Become Fragments

The desktop UI is a single window with a left-right split pane. On a phone, that translates badly. The Android port uses a ViewPager2 with three tabs — Friends, Chat, Settings — driven by ChatUI as the conceptual ancestor but with a layout idiom appropriate to a 6-inch screen.

Claude's internal reasoning A JSplitPane simply doesn't translate. On mobile the user expects swipe-able sections, with the chat view occupying the full screen when active. ViewPager2 backed by a FragmentStateAdapter is the canonical pattern. Selecting a friend in the Friends tab swipes the user to the Chat tab automatically — the same affordance as clicking a row in UsersView on desktop, but adapted to the touch idiom.

Each fragment subscribes to controller events on resume and unsubscribes on pause, so rotating the device or backgrounding the app never leaves a dangling listener. FriendsFragment mirrors UsersView row-for-row: pending requests, accepted friends, groups. ChatFragment mirrors ChatView: a vertically scrolling RecyclerView of messages, an EditText composer, an attach button. The emoji catalogue from Emoji.java is reused verbatim — it is just a static table.

AppSecChat showing a group chat with end-to-end encrypted messages
The Android port: a group chat exchanging end-to-end encrypted messages.

Networking on Android

The desktop client uses the JDK 21 HttpClient. Android still ships an older Java baseline and forbids network I/O on the main thread. The port introduces a thin AndroidClient wrapper around HttpURLConnection that:

  1. Submits every request to a fixed-size ExecutorService.
  2. Posts results back to the main thread via Handler.post().
  3. Carries the auth token and crypto material identically to the desktop client — same header names, same JSON shape — so the server cannot tell which client it is talking to.
Prompt "On Android the network calls must not block the UI thread. Wrap the HTTP client so the callers don't have to think about threading."

Persisting State with SQLite

The desktop reference uses an embedded SQLite database via JDBC. Android has its own built-in SQLite stack, so the port supplies a fresh implementation of the ILocalDS contract — AndroidLocalDS — backed by two SQLiteOpenHelper instances: one for channels, one for messages.

The schema is preserved exactly: the same column names, the same primary keys, the same indexes. A user migrating from desktop to mobile would see the same data shape on disk.

Claude's internal reasoning The temptation here is to reach for Room. I'm resisting it. Room adds an annotation processor, a DAO layer and a lifecycle dependency, and the existing code already speaks raw SQL through ILocalDS. Mirroring the desktop schema with two SQLiteOpenHelper classes is fewer moving parts, easier to debug, and trivial to port back to the desktop side if a bug is found.

Background Polling

Desktop polling is a one-liner: new Timer(3000, e -> controller.poll()).start(). On Android, that pattern breaks the moment the screen turns off. The port replaces it with an AlarmManager-driven BroadcastReceiver:

// Wake roughly every 60 seconds, even with the screen off.
AlarmManager am = (AlarmManager) ctx.getSystemService(Context.ALARM_SERVICE);
PendingIntent pi = PendingIntent.getBroadcast(ctx, 0,
        new Intent(ctx, PollAlarmReceiver.class),
        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
am.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP,
        SystemClock.elapsedRealtime() + 60_000,
        AlarmManager.INTERVAL_FIFTEEN_MINUTES, pi);

When the receiver fires, it acquires a WakeLock, asks the controller to fetch pending messages, and posts a notification if anything arrived. The desktop's hot loop is fine on a plugged-in laptop; on a battery-powered phone the right behaviour is to wake rarely, do a small amount of work, and go back to sleep.

From Code to Play Store

Vibe coding does not end at the source tree. Shipping the app demanded a launcher icon, a 1024 × 500 feature graphic, a privacy-friendly screenshot, signing config, a target-SDK bump to 36, and an internal-testing release on the Play Console. Each of these was a one-prompt task:

No part of this work involved Claude searching for documentation. It already knew the Play Console's asset specs, the Gradle DSL for signing configs, and the R.mipmap naming conventions. The user described the outcome; Claude produced a buildable APK.

What Changes at Project Scale

The previous post was a single file. This one is a 25-class Android project sharing a JAR with a desktop client and shipping to a real app store. The interesting observation: the vibe-coding rhythm did not change.

The prompts stayed at the level of intent. The artifacts grew from a webpage to a signed Android binary. The friction stayed flat.

What scales here is not the LLM's typing speed — it is its ability to hold the whole project in working memory. When the user said "reuse shared_client.jar", Claude already knew which classes that jar exposes, which were extended on the desktop side, and which would need fresh Android implementations. When the user said "tabs not split pane", Claude already knew that the friend-selection event in UsersView was the natural trigger for switching the active tab on Android.

The reference implementation lives at github.com/Secchats-tech/ChatJavaUI. The Android port lives on the Play Store. Both speak the same wire protocol and answer the same open API. Both were shaped one sentence at a time.


Java Swing reference implementation: github.com/Secchats-tech/ChatJavaUI/tree/main/src.