Ramiz RajaBook a call
← Writing
Mobile Architecture

Twelve Apps, Nine Kits, and a Theme Function Still Called ResizePingTheme

I build a family of small branded apps. Some of the code is shared through kits, some of it was copied from one app into the next. Only one of those paths lets a fix reach every app.

Ramiz RajaNovember 12, 20267 min read
Twelve Apps, Nine Kits, and a Theme Function Still Called ResizePingTheme

Open the theme file of GifPing, a GIF maker, and the main composable is called ResizePingTheme. Open EraserPing, a background eraser, and it is called ResizePingTheme there too. Neither app is a photo-resizing product. That function name is what a white label app family looks like when part of it was built by copying.

I build the Ping apps: twelve small branded apps on pingappslab.com, eight on Google Play and four on the App Store, with four more still marked coming soon. They are built from nine shared kit repos.

What "white label app" means, and what it means here

Search "white label app" and the results are agency and app-builder pages. There, the white label app meaning is commercial: one company builds an app, another puts its name and logo on it and sells it. The question those pages answer is whether to buy or build.

My situation is different. I own every app in the family, so nobody is reselling anything. The engineering problem is the same one, though: many apps that each need their own store identity, brand and products, without maintaining twelve copies of the same ad, UI and AI code.

That problem got sharper once coding agents made a new small app cheap to start. Starting is the easy part. The hard part is that each app's store identity, palette, billing products and ads setup differ, and copied code drifts the day after you copy it.

The short version: code with a second consumer goes into a kit pinned by each app; the product and its identity stay in the app. This post shows the evidence for that rule, including where copying still costs me.

White label app development with shared kits

Every app uses at least one kit, and ten of the twelve pin them as git submodules. A mobile kit is its own repo with a native Android implementation and a native iOS one, a written contract, and shared JSON fixtures that both sides must pass. They are not Kotlin Multiplatform; each platform stays native.

Table: the nine kits. Each is a separate repo; most are pinned by commit, not pulled from a package registry, and one has no consumer yet.

KitWhat it ownsHow apps get it
ping-ui-kitCompose and SwiftUI foundation: theme, tokens, componentsgit submodule
ping-ads-kitAdMob, consent, banner lifecyclegit submodule
ping-ai-sdkProvider adapters, streaming, tool callsgit submodule
ping-ai-sdk-uiChat screen on both platformsgit submodule
ping-shared-kitOn-device OCR for phone numbersgit submodule
ping-media-kitResize, compress, EXIF, PDF exportsibling clone
ping-review-kitIn-app review promptsgit submodule, one app so far
ping-model-kitOffline model download contractno app pins it yet
ping-webui-kitWeb UI for Cloudflare Workersnpm package

On Android, an app's settings.gradle.kts includes the submodule as a build, for example includeBuild("vendor/ping-ads-kit/android"). On iOS, XcodeGen's project.yml points a local Swift package at vendor/ping-ui-kit/ios. The app builds the kit from source at exactly the commit it pinned.

The ads kit's README draws the line I care about most: "An app provides only its AdMob app ID, per-placement ad unit IDs, its entitlement stream, and its banner locations. The kit owns UMP consent, ... initialization, banner lifecycle, sizing ..." Nine Android apps and six iOS apps import it.

What each white label mobile app owns

None of my apps use Android product flavors. Each app is its own repo with its own applicationId, iOS bundle ID, name and icons. What each app owns comes down to a short list.

Table: what differs between three apps that share the same ads and UI kits. Values are from each repo.

ResizePingGifPingRecallPing
Android applicationIdcom.plannedping.resizepingcom.plannedping.gifpingcom.pingappslab.recallping
Billing productresizeping_pro_lifetimegifping_pro_monthlylifetime, ID set at build time
Themeown palettecopied from ResizePingown palette passed to PingTheme
ads-config.jsonandroid.{appId, placements, networkAppKeys}same keyssame keys

The palette row has a story. The kit's color scheme was always meant to be neutral: its own docs called the default primary "never a specific Ping-family app's brand color". But PingTheme had no parameter for an app's palette. An app could ship the placeholder as its identity, or stop using PingTheme and lose the kit's typography, shapes and spacing.

StatusPing took the first path. It ended up blue-slate on a warm cream background while its shipped siblings were green-teal. The fix went into the kit, not the app: PingTheme gained an optional colorScheme parameter.

Code: the kit ships a neutral default that is nobody's brand; apps using PingTheme can supply their brand palette while keeping the kit's defaults. Excerpt from ping-ui-kit and RecallPing.

// ping-ui-kit
@Composable
fun PingTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    colorScheme: ColorScheme? = null,   // the app's brand palette
    colorRoles: PingColorRoles? = null,
    typography: Typography? = null,
    dynamicColor: Boolean = false,
    content: @Composable () -> Unit,
)

// RecallPing
PingTheme(
    darkTheme = darkTheme,
    colorScheme = if (darkTheme) RecallPingColorScheme.dark() else RecallPingColorScheme.light(),
    typography = RecallPingTypography,
    content = content,
)

The KDoc now says it plainly: an app that ships with the default "is shipping the placeholder". Brand lives in the app; everything else about the theme lives in the kit.

The fork path, and what drifted

Not every app was built from kits.

GifPing's Android repo starts with a commit titled "chore: new GifPing Android identity from ResizePing". EraserPing's Android and iOS repos start with "chore: seed from GifPing origin/main (no history, no keystore, no firebase config)". The Android seed added 1,136 files in one commit, the iOS seed 809. LeadPing was bootstrapped from the PlannedPing scaffold the same way.

Forking is fast. I wanted to know what it left behind, so I grepped. GifPing still has 19 Kotlin files that mention ResizePing, EraserPing 18. Both define ResizePingTheme, which wraps MaterialTheme directly instead of the kit's PingTheme. Both carry their own copy of PlayBillingManager.kt.

That billing file is the honest counterexample in this post. Billing is not a kit. ResizePing's copy is 247 lines and GifPing's is 251; after normalising the package names, 28 lines differ.

GifPing's and EraserPing's copies are identical after the same normalising, and EraserPing's product ID still has a // TODO-STORE: replace with real product id comment. A fix to one copy reaches no other app unless someone ports it by hand.

The kits themselves were copied too. Both forks' Android repos still declare the kits in .gitmodules, but in all four fork repos git ls-tree shows plain directories in vendor/, not submodule pointers. When EraserPing Android needed a newer ads kit, the update was a copied tree: 83 files, 5,228 lines added. GifPing has not updated its copy since the seed.

These seeded repos have no automatic update path from their source. That is fine for code that really is product-specific. It is a cost for code that is the same everywhere.

One kit release, eight app repos in two days

The ads kit is where the shared path pays. Between one release that nine app repos pinned on the same day and the next one I tracked, 22 pull requests merged in the kit: banner retries, landscape insets, and an iOS fix so that opening the privacy options no longer threw away the loaded banner.

Table: how the later release reached the apps. Each app-side change is a submodule pointer move, usually committed with a version bump.

App reposPicked up the release
ChatPing Android and iOS, RecallPing, ReelSavePing Android and iOS, ResizePing Android and iOS, VideoDownloaderPingwithin two days
PlannedPing Androidabout three weeks later

In an app repo, the kit part of the diff is one line: Subproject commit <old> becomes Subproject commit <new>. Compare that with EraserPing's 83-file copy. Twenty commits in ResizePing's Android repo touch the ads-kit dependency, including its initial adoption. The kit has 48 tags, and each app chooses when to move.

That choice is the point of pinning, and also its cost. PlannedPing ran on the older kit for three weeks because nobody moved its pointer.

A package registry with a lockfile gives the same control; the difference is that a submodule builds the kit from source, so I can read and debug it in place. Either way, each move is a commit.

Kits only stay shared if apps cannot reach around them. I wrote about enforcing that boundary, and how it leaked anyway, in Clean Architecture With AI Agents. The same rule applies here: an app that imports a kit's internals is exposed to any change outside the kit's contract.

When I fork and when I extract a kit

I do not have a measured time-to-launch for a new app, and I will not invent one. What I can show is the rule I follow, and the evidence above is where it came from.

Table: the decision as I apply it now.

The codeWhat I doWhy
Behaves the same in every app (ads, consent, theme, chat UI, OCR)Extract a kit, contract and fixtures firstOne fix, one pull request, one pin per app
Is the product (GIF editor, eraser, resize flow)Fork or write it in the appIt has no second consumer to share with
Is copied today and nearly identical (billing manager)Candidate for the next kit28 lines of drift across three copies is the warning
Is identity (IDs, palette, products, ad config)Keep in the app, pass it into the kitThe kit should never know which app it is in

If you are planning a white label mobile app family, the logo swap is the smallest part of it. Decide early which code has a second consumer, give that code its own repo, and pin it.

Everything else can be copied, as long as you know that copy has no upstream. The setup for running agents across those repos is in Two Agents, One Repo.