Four Weeks of Building Buildhorn for Shipaton 2026

Wait 5 sec.

I’m officially at the four-week mark working on Shipaton, and another progress update is here.Note: This is the 4th post about my work towards shipping an app for the Shipaton 2026 hackathon. If you want to read from the beginning you can find the link to the first post hereAs a reminder, I’m building a multi-layered application called Buildhorn so users can receive CI build failures direct to their phone. I’m using the following technologies:Kotlin Multiplatform (Shared Kotlin Library, handles networking, file storage and business logic)iOS App (iOS app built using SwiftUI, focuses on the UI layer and consumes the Kotlin Multiplatform library for its business logic)Firebase Backend (An all-in-one backend using Firebase Cloud Functions for endpoint creation / webhook event handling, Firebase Cloud Messaging for push messaging and Firestore for backend persistence)The past week has been focused on testing the RevenueCat paywall and making sure it is hooked up correctly to App Store Connect. I have also integrated Firebase Crashlytics into the app to ensure I have observability in the case of an app crash.This tends to be the more unglamorous part of productionising an app as it doesn’t involve much coding. It does however require a lot of attention to detail, triple checking things are set up correctly and challenging assumptions to ensure they hold up. Any code changes have been minimal or mostly refactoring.This effort makes it less likely that an embarrassing bug slips through, or that last-minute tweaks are needed. I’d recommend going through this for any app to make your initial release the best it can be!With that in mind, here’s what’s new this week:Configured Subscriptions in App Store Connect and imported them into RevenueCatUpdated TestFlight builds to use the RevenueCat Apple API key for end to end sandbox testing for subscriptionsSet up Firebase Crashlytics for crash reporting, including tracking when exceptions occur in the KMP layerCleaning up the Koin DI modules so they are more logically groupedLet’s dive deeper and look at how each part of the application is contributing to these changes.Kotlin MultiplatformThis week the KMP library had the following developments:The ability to record exceptions into Firebase CrashlyticsA cleaner Koin module setupLet’s look at each of these separately.Recording exceptions into CrashlyticsIntegrating Crashlytics exception recording was relatively straightforward thanks to the Firebase Kotlin SDK by GitLive. Over the weeks I’ve come to appreciate how much support this open source library provides. I consider it a core part of the KMP library for Buildhorn as it also handles calling Cloud Functions for the backend integration and Firebase Auth for creating an anonymous id for users.The exception handling is dealt with via an ExceptionReportingService and called to via an ExceptionReportingRepository.import dev.gitlive.firebase.Firebaseimport dev.gitlive.firebase.crashlytics.crashlyticsclass ExceptionReportingService { fun recordException(throwable: Throwable) { Firebase.crashlytics.recordException(throwable) } fun setUserId(userId: String) { Firebase.crashlytics.setUserId(userId) } fun log(message: String) { Firebase.crashlytics.log(message) }}class ExceptionReportingRepositoryImpl( private val service: ExceptionReportingService,) : ExceptionReportingRepository { override fun recordException(throwable: Throwable) { service.recordException(throwable) } override fun setUserId(userId: String) { service.setUserId(userId) } override fun log(message: String) { service.log(message) }}These two classes enable any other class within the KMP library to use the repository as part of its own exception handling for tracking. Here is an example of how connecting the installation id from a GitHub profile records an exception in the event of an error from the Cloud Function:suspend fun connectInstallation(installationId: Long): ConnectGitHubInstallationResult = try { val response = functions .httpsCallable(CONNECT_INSTALLATION_FUNCTION) .invoke(ConnectInstallationRequest(installationId)) .data() ConnectGitHubInstallationResult.Success( InstallationInfo( accountLogin = response.accountLogin, accountType = response.accountType, repositorySelection = response.repositorySelection, accountAvatarUrl = response.accountAvatarUrl, ), )} catch (exception: FirebaseFunctionsException) { // The exception is passed to crashlytics here. exceptionReporting.recordException(exception) ConnectGitHubInstallationResult.fromExceptionCodeName(exception.code.name, exception.message)}Now with one line of code we can pass an exception along to Crashlytics, giving us visibility of any issues that may happen. This is used across the KMP library to give as much visibility into potential issues as possible.Cleaning up the Koin ModuleUp to now I’ve had a single Koin module dealing with all the dependencies grouped together. Technically, this is ok since the KMP library is composed of one module. It does make for a messy collection, take a look at how it was beginning to look:val sharedModule = module { singleOf(::OnboardingLocalDataSource) single { OnboardingRepositoryImpl(get()) } single { GetHasCompletedOnboardingUseCaseImpl(get()) } single { SetHasCompletedOnboardingUseCaseImpl(get()) } single { Firebase.functions(FUNCTIONS_REGION) } singleOf(::GitHubConnectService) single { GitHubConnectRepositoryImpl(get(), get()) } single { SignInAnonymouslyUseCaseImpl(get()) } single { ConnectInstallationUseCaseImpl(get()) } single { GetStoredInstallationIdUseCaseImpl(get()) } single { ListAvailableRepositoriesUseCaseImpl(get()) } single { GetSelectedRepositoriesUseCaseImpl(get()) } single { UpdateSelectedRepositoriesUseCaseImpl(get()) } single { GetConnectedProvidersUseCaseImpl(get()) } single { RegisterFcmTokenUseCaseImpl(get()) } viewModel { ProvidersViewModel(get(), get()) } // More dependencies below. You get the picture.}To change this, I decided to split the sharedModule up into multiple Koin Modules. Now the modules look like this:private val exceptionReportingModule = module { singleOf(::ExceptionReportingService) single { ExceptionReportingRepositoryImpl(get()) }}private val onboardingModule = module { singleOf(::OnboardingLocalDataSource) single { OnboardingRepositoryImpl(get()) } single { GetHasCompletedOnboardingUseCaseImpl(get()) } single { SetHasCompletedOnboardingUseCaseImpl(get()) } viewModel { OnboardingViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }}private val authModule = module { single { SignInAnonymouslyUseCaseImpl(get()) }}private val gitHubProviderModule = module { single { Firebase.functions(FUNCTIONS_REGION) } single { GitHubConnectService(get(), get()) } single { GitHubProviderRepositoryImpl(get(), get()) } single { ConnectGitHubInstallationUseCaseImpl(get()) } single { GetStoredGitHubInstallationIdUseCaseImpl( get() ) }}private val providersModule = module { viewModel { ProvidersViewModel(get(), get()) } single { GetConnectedProvidersUseCaseImpl(get()) } single { ListAvailableRepositoriesUseCaseImpl(get()) } single { GetSelectedRepositoriesUseCaseImpl(get()) } single { UpdateSelectedRepositoriesUseCaseImpl(get()) }}// More organised modules...Now I can take a look at the module file and find the group I want to make changes to without wading through the entire dependency list.iOS AppThis week the iOS app had two significant improvements:Firebase Crashlytics IntegrationRevenueCat Subscription Sandbox Testing SetupCrashlytics IntegrationFor the iOS app I needed to make sure the Firebase Crashlytics framework was included via Swift Package Manager (SPM). Fortunately once this was done no further work was needed as Firebase was already being configured once the app starts.@mainstruct iOSApp: App { // App properties init() { FirebaseApp.configure() #if DEBUG KoinInitKt.doInitKoin(isDebugBuild: true) #else KoinInitKt.doInitKoin(isDebugBuild: false) #endif }The Firebase libraries are cleverly designed so each different aspect of the Firebase SDK is configured the same way.One interesting thing to know is FirebaseApp.configure() has to be called before the Koin modules are instantiated. This is because the dependencies in Koin instantiate Firebase components within the KMP library and must be able to rely on the configuration provided by FirebaseApp.You may notice the KoinInitKt calls are split with a different parameter passed. That leads us nicely onto setting up the RevenueCat paywall.Setting up RevenueCat Subscription Sandbox TestingOne of the eligibility requirements for Shipaton is to ship an App that provides a paid subscription using the RevenueCat SDK. That fits nicely with Buildhorn’s planned sales strategy to provide a free unlimited trial using 2 watched repositories, with the ability to subscribe for unlimited repository watching.The idea is to give users enough to try out the app with, and hopefully once they see the value decide to subscribe to gain full usage across their GitHub repositories.Setting up subscriptions can be a tricky process however and that’s where RevenueCat comes into it. They simplify the process and provide a whole suite of tooling around the process of setting up / managing subscriptions. Including providing an SDK, web dashboards, and more.I first began by ensuring my integration of the RevenueCat KMP SDK was correct. Their onboarding flow for each new project is well thought out, they even provide a prompt you can share with your coding agent to speed up getting setup with the SDK:Once you’re set up the next step is to create a paywall. RevenueCat provide a paywall editor to help design your paywall and importantly ensure it adheres to each platform’s expectations of what a paywall contains. Apple & Google can be quite strict in their rules around this.Unfortunately I found the wizard to be lacking in my use of it. I was relying on their built in AI tool to quickly help build the paywall, which seemed to have trouble constructing what I was asking for via a prompt.In the end I defaulted back to Claude Design to provide the paywall design, and then asked Claude Code in Xcode to implement the design in native SwiftUI. Once that was done I then asked it to hook up the CTAs so it calls through to the RevenueCat SDK within the KMP shared library.With the paywall setup you can begin to test your setup using RevenueCat’s test store. Ideal for a quick verification that everything works so far, if you want to test using real subscriptions you need to connect RevenueCat to the App Store.The setup process has a number of steps so I won’t go through every step. Roughly it looks like this:Create a subscription group in App Store ConnectCreate a subscription to exist within the groupConnect App Store Connect to RevenueCatImport your products into RevenueCat and set them upYou can find detailed steps in RevenueCat’s documentationOnce the App Store is connected, you can begin to test using the platform store API key provided by RevenueCat.Here is an example of how that looks in our KMP shared library:internal expect val productionRevenueCatApiKey: Stringinternal fun configureRevenueCat(isDebugBuild: Boolean) { Purchases.logLevel = LogLevel.DEBUG val apiKey = if (isDebugBuild) TEST_STORE_API_KEY else productionRevenueCatApiKey Purchases.configure(apiKey = apiKey) { // Buildhorn has no account system of its own, so start RevenueCat off with its own // generated anonymous app user id. SubscriptionService.identify() later aliases this to // the Firebase anonymous uid, at purchase time, once one is available. appUserId = null }}The isDebugBuild parameter is what is passed in from KoinInitKt.doInitKoin(isDebugBuild: true) further above. This allows us to switch between using the test store API or the productionRevenueCatApiKey for the App Store.You may also notice the expect keyword above; that’s KMP’s way of saying the value is expected to be here but it will be provided by the platform.The way the platform provides the api key is via a folder in the KMP library called iosMain. This is a source set of files built when the iOS app is built. Here’s an example of the implementation:internal actual val productionRevenueCatApiKey: String = "appl_weewropDJWQPOAKasqukfKANxJQziX"Here the actual keyword is used, which signifies to KMP this is the implementation to use in conjunction with the expect value.This is what gives KMP its ability to work across platforms whilst being relatively unintrusive. It doesn’t enforce anything on you, it lets you leverage as much as you wish.You can read more about the expect / actual setup in KMP’s documentation.With that setup Buildhorn can now retrieve real subscriptions from RevenueCat for testing.AI Tooling / UsageFor all the changes mentioned in this blog post I’ve continued to leverage Claude / Junie to support the development via a CLAUDE.md / AGENTS.md file to constrain the AI to my project requirements. With this in place I’m still continuing to “vibe code”, that is keep prompting Claude or Junie until I have the desired outcome.The integration of Crashlytics using AI tools went smoothly, as Claude was able to integrate both the KMP library and the iOS app at the same time. The testing was done manually to make sure it works end to end.Next StepsFour weeks into Shipaton and the 1st release of Buildhorn inches ever closer. My focus for the next week is:Last checks for Buildhorn to ensure the v1.0 is ready to ship!Work on the App Store listing for BuildhornGet the Buildhorn website into a v1.0 state for the App Store listingSubmit Buildhorn to App Store Connect and address any feedbackThat’s all for this post. If you would like to help test the app, you can join the TestFlight external testers group.Thank you for reading and keep an eye out for the next post on my progress building for Shipaton 2026!