Skip to content

kmp

On mobile, and increasingly on desktop, every app needs a solid navigation system. It isn't just a UI pattern (like master-detail) or managing back gestures; it's the very skeleton of the user experience and a fundamental block in the user's mental model. When taking certain actions, users expect to transition from one fullscreen page to another; when taking others, they expect a modal (dialog or bottom sheet) to appear, and so on.

Why Navigation is Complex

Unfortunately, Navigation is rarely straightforward. In declarative UI paradigms like Compose, components render a state that needs to be retained across recompositions. Depending on the platform, this state must also survive configuration changes and process death.

As I discussed in a previous article, state holders have lifecycles bound to a view's visibility. When a view enters the viewport, the holder is born; when it's gone, it's destroyed to free up memory.

Sometimes, parameters need to be passed back and forth between navigation entries: input data for an item accessed from a list, or output data from a user selection. Other times, "shared data" must be retained across several entries, such as in wizards or user journeys spanning multiple screens.

All of this has deep implications for DI, memory management, and app architecture.

Choosing a Path

When I started with CMP in 2023, JetBrains hadn't yet released an official navigation solution. The community filled this void with several alternatives:

In my early experiments, I tested Precompose and Decompose on desktop but ultimately chose Voyager for mobile. Its practicality was its biggest selling point: the "Holy Trinity" of Screen, ScreenModel, and Navigator made everything feel easy and compact.

The Voyager Reality Check

To be honest: something felt off right from the start: the model was quite intrusive. In Compose, all UI elements (including fullscreen pages) are functions, but in Voyager, screens were objects. Over time, as complexity piled up, cracks began to show.

The Serialization Trap

I discovered the hard way that Screens aren't regular objects that can hold any instance variables. On Android, for example, all their members must be either primitives or types that can be stored in a Bundle.

To handle multiplatform state restoration, you have to define an expect interface,1 actualized by a tagging interface on non-JVM platforms and by a typealias of java.io.Serializable (details here).

This was quite fragile and I remember it breaking during Gradle/AGP upgrades.

I eventually opted to fall back on primitives (like IDs) for passing data between screens and added a custom cache layer just to store items before a transition.

Even before tackling adaptive layouts, I had to deal with nested navigation (main stack + bottom bar + drawer). I ended up encapsulating the logic into adapters and helpers (like NavigationCoordinator and NavigationAdapter, plus a MainRouter to manage modularization by feature).

Then there were deep links. Voyager's own documentation admits that it doesn't provide a built-in solution for handling deep link URIs.

Switch to Navigation Compose

In 2024, JetBrains ported Navigation Compose to Multiplatform, starting with version 1.6.10. By the end of that year, version 1.7.0 also introduced type-safe navigation.

Therefore, in mid-2025, I took the opportunity to migrate the whole project. It wasn't easy, it took roughly one week per project,2 but moving to a standard solution felt like finally having solid ground under my feet.

Ironically, the adapters I created to encapsulate Voyager's workarounds proved useful. They decoupled the client project from the navigation library itself, so the migration mostly involved changing those internal classes: instead of wrapping a Navigator instance, they wrapped around a NavController.

But the story didn't end there. By late 2025, I planned to add desktop support and improve layouts for tablets and foldables. This meant dealing with Adaptive Layouts: representing master-detail as two separate screens on small devices but as a two-pane single screen on larger ones (using ListDetailPaneScaffold).

In Navigation Compose, I ended up with two different navigation graphs: dedicated two-pane destinations for large screens and separate destinations for mobile.

Enter Navigation 3

Meanwhile, Google released Navigation 3. Its biggest advantage was giving the client app full ownership of the backstack. It also introduced SceneStrategy, which decoupled navigation entries from their layout strategy. This meant master and detail destination definitions could remain independent of how they were presented.

Navigation 3 entered CMP in version 1.10.0 at the start of 2026. Roughly a year after moving to Navigation Compose, I decided to re-migrate everything to Navigation 3.

For those interested, here is my migration checklist:

  • ensure all destinations comply with the NavKey interface and setting up SavedStateConfiguration for multiplatform state restoration;
  • replace old constructs (NavHost, NavController, NavGraph) with the new ones (NavDisplay, NavBackStack, NavEntryDecorator, SceneStrategy);
  • remove ListDetailPaneScaffold usages and cleaning up the dual graph definitions;
  • tests, tests and more tests (both automated and manual ones).

This time, my adapters turned against me. The recommended approach for Navigation 3 is to use a NavigationState holder and a Navigator class (see the migration guide), whereas I was relying on NavigationCoordinator and NavigationAdapter.

Eventually I chose to keep my existing surface API but updated the internal implementation, which was also a great excuse to polish some "encrustations of time" like an old throttling mechanism which was not needed any more.

Lessons Learned

If I could go back to 2023, here is what I'd tell myself:

  1. Standardization > Easiness: A compact library might save time today but cost months tomorrow.
  2. Mind compromises: Don't pick a library that treats deep links as an afterthought, choices can backfire.
  3. Build Adaptive from Day One: Even if you're only on mobile now, you'll want those extra pixels later.
  4. Be prepared to change: As already stated in other articles in this blog, changes are inevitable, so embrace them as improvement opportunities.

  1. Expect/actual classes and interfaces are in Beta. You can opt in with the compiler flag -Xexpect-actual-classes. ↩

  2. Specifically, migrating both the Mastodon/Friendica client and the Lemmy client. ↩

One Preview to Rule Them All: Unified Compose Previews in Common Code

Until late 2025, managing UI previews in a Compose Multiplatform (CMP) project felt like navigating a minefield of compromises. The ecosystem was fragmented, and achieving a seamless "write once, see everywhere" experience was surprisingly difficult.

The Fragmented Past of CMP Previews

Back in the dark ages, there were three distinct ways to handle previews:

  1. androidx.compose.ui.tooling.preview.Preview: the standard for androidMain, well-supported by Android Studio and IntelliJ IDEA, requiring compose.preview in your dependencies;
  2. androidx.compose.desktop.ui.tooling.preview.Preview: specific to desktopMain, supported by IDEs via a plugin with minimal build setup;
  3. org.jetbrains.compose.ui.tooling.preview.Preview: the common option that worked across all source sets, but with limited IDE support (mostly restricted to Fleet), requiring compose.components.uiToolingPreview in commonMain.

For my workflow, none of these were perfect. I never found Fleet to be a viable daily driver due to its early-stage stability and uncertain pricing model, so option #3 was out. Since my focus was primarily on mobile, the desktop-only preview (#2) wasn't of much help either.

That left me with the Android-specific annotation (#1). However, since this annotation was only recognized within the androidMain source set and all my Composables lived in commonMain, I would have been forced to maintain previews in separate files. Worse yet, I couldn't see them unless I kept the Android counterpart open in a parallel pane.

So I decided to wait for the ecosystem to mature. After all, the friction only affected my Developer Experience, end-users were not affected in any way.

The Turning Point

The game changed with the release of Compose Multiplatform 1.10.0. This version introduced a unified androidx.compose.ui.tooling.preview.Preview annotation that can be used directly in commonMain and is fully recognized by both Android Studio and IntelliJ IDEA.

Of course, a unified experience requires some intentional configuration. To get this working some steps are needed:

  • add the org.jetbrains.compose.ui:ui-tooling-preview dependency to your commonMain source set;
  • if you apply the com.android.kotlin.multiplatform.library plugin, you must ensure org.jetbrains.compose.ui:ui-tooling is available in the androidRuntimeClasspath.

In my project, I manage configuration through a convention plugin. I had to adjust my KotlinMultiplatformAndroidLibraryExtension setup to inject the necessary runtime dependency:

class ComposeMultiplatformPlugin : Plugin<Project> {
    override fun apply(target: Project): Unit =
        with(target) {
            extensions.configure(KotlinMultiplatformExtension::class.java) {
                targets.withType(KotlinMultiplatformAndroidLibraryTarget::class.java)
                    .configureEach {
                        apply<KotlinMultiplatformAndroidLibraryExtension> {
                            dependencies.add(
                                "androidRuntimeClasspath",
                                libs.findLibrary("compose-ui-tooling").get(),
                            )
                        }
                    }
            }
        }
}

The Resource Roadblock

I thought I was in the clear. But as it turns out, I had only scratched the surface. As soon as I started adding the first preview, I realized that my previous architectural decisions regarding resource management were blocking the way.

My UI relied heavily on CompositionLocals for drawables and localized strings. Furthermore, resource access was abstracted away through interfaces (CoreResources and Strings), with concrete implementations (DefaultCoreResources and DefaultStrings) locked inside the :shared subproject.1

This architecture was a relic of an era before native Compose resources, back when I used Lyricist and moko-resources. This setup had remained incredibly flexible though, and I wasn't ready to abandon it just for the sake of previews.

To preserve both subproject-specific previews and clean encapsulation, I performed a targeted refactor. I moved the concrete resource adapters from :shared to their respective modules (:core:resources and :core:l10n). I also exposed the DI bindings via public modules (resourceModule and l10nModule), which were previously internal in :shared.

Eventually, I created an extension function in :core:commonui:components to factor out the DI setup for previews:

@Composable
fun RootDI.SetupPreview(vararg modules: DI.Module) = remember {
        di = DI {
            importAll(resourcesModule, l10nModule, *modules)
        }
    }

This refactoring didn't just give me functional previews in common code; it also allowed me to eliminate redundant test doubles for resources. Because my unit tests now have access to the actual resource adapters within their subprojects, the tests are both simpler and more realistic.

Closing Thoughts

Unified previews have bridged the gap between common code and visual feedback, making the "multiplatform" part of Compose truly first-class. In the future, I'll be adding more of them (as of now, I concentrated on :core:commonui:components as I was experimenting): it makes it easier to maintain the project and spot bugs earlier.

In my case, I believe waiting until a mature solution emerged was a winning strategy. Balancing trade-offs between functionality and complexity is worth if it improves UX, but here it only involved DX (and this is mostly a one-man project); so it could be put off with no impact.

Finally, as always, adopting new parts of a technology was an opportunity for me to improve code architecture, remove obsolete workarounds and cleanup boilerplate. And this is one of the main reasons I work on this project: learn new things, refactor, improve code quality and ultimately myself as a developer.


  1. In this context "subproject" and "module" can not be used interchangeably. From now on, " subproject" will refer to a build configuration unit, "module" will refer to a DI configuration unit. ↩

Multi-module architecture and shared build logic

Raccoon apps are Gradle projects, meaning that all tasks (compilation, verification, packaging) are orchestrated by a tool called Gradle. In this article we will explore the relationships between build system and project structure, discuss why some architectural choices are better and how to configure builds so that the tool works for you (and not vice versa).

What is Gradle?

Gradle is a general-purpose tool for projects written in Java, C++, Scala, JavaScript, Groovy and Kotlin or any combination thereof. It is an extremely powerful tool, suited for enterprise-scale projects (backend or full stack applications, Android apps, KMP apps and so on). It is designed for extensibility, with a wide range or third party plugins which make it very flexible and adaptable, at the cost of a quite steep learning curve. Setting it up correctly is just the first step, being able to analyze build traces, identify errors and fix them indeed takes some years of seniority.

Nonetheless, Gradle is still the de facto standard for Android and KMP projects as of 2026, even if there are alternatives. For example the Kotlin toolchain, backed by JetBrains itself, is very promising and, personally, I foresee that in the future the community will gravitate towards leaner and more specialized tools like that.

Info

Gradle is deeply integrated into the Android ecosystem (e.g. with AGP), so it will likely remain the requirement for Android-specific packaging in the foreseeable future.

Build Nightmares

If Gradle is not configured properly, there are several downsides. The most obvious one is a build failure, which can give some headaches but at least you realize immediately that something is wrong.

But there are more subtle ones: for example its performance may degrate significantly, and as the project grows you end up wasting more time waiting for builds to finish rather than actually working on the project.

Or, which is worse to me, your build config becomes unmaintainable. As technology evolves or plugins are added, you end up with lots of interconnected yet sparse build scripts and config files scattered in your source tree. Each new change may be incompatible with other ones and break everything at the least expected moment in an endless dependency nightmare.

Now, let's move into the solution space. To control build times, the most important thing is to avoid running unnecessary steps and caching results from already performed steps to reuse them in subsequent builds. This is a wide topic and would require several chapters to properly discuss it.

However, the key idea is that building a project is a sequence of interconnected tasks, where the output of each one may be the input for another one. This concept in CS is commonly referred to as a "pipeline", and all built tools from the early days work in this way (GNU Make, Apache ant, Maven, etc.).

Since tasks are deterministic, if all the inputs of a task are unchanged, its output will be unchanged too so it may be skipped. So, for example, if the sources of a specific compilation unit are not changed from a previous run, the corresponding binary does not need to be recreated. But sources are not the only input of the build step of a given unit, it may also depend on the APIs of other units and if those are changed the current one is affected too.

The key to avoid unnecessary passes is to isolate the project in discrete components with well-defined rules about which units depend on which other ones in order to minimize coupling and, therefore, the amounts of rebuilds whenever changes occur.

Modularization

In Gradle projects, this boils down modularization strategies. Every Gradle project consists of a root project, i.e. the container project which results in the main artifact when the build is completed (this may be a .ear, .war, .jar, .apk or whatever package depending on the applied plugins) and one or more subprojects whose build outputs are assembled into the main artifact. So, the assemble task of the root projects depends on the output of the assemble task of each subproject.

"Depending" in this sense has two negative consequences affecting build times:

  • the dependant task needs to wait until all its dependees have completed;
  • every time a dependency changes, the dependant is invalidated and needs to rerun.

Building each subproject can happen in parallel, so having more than one is usually beneficial in modern multicore architectures. However, if there are dependencies between subprojects, the two aforementioned consequences for each dependant apply.

If subprojects are seen as nodes and a dependency is a directed edge pointing from the dependant to the dependee, Gradle projects have the form of a DAG, implying that all builds end sooner or later (no cycles).

But some configurations are worse than others for performances. Let's consider the following configuration for example:

stateDiagram-v2
    [*] --> C
    [*] --> D
    C --> B
    B --> A
Diagram illustrating the "chain" configuration.

where we see that for the build to complete C and D are needed, but C needs B to complete, and B needs A to complete in turn. This means that A, B and C can not be run in parallel and that each change in A affects B, C and the overall result.

Let's also consider the following scenario:

stateDiagram-v2
    [*] --> B
    [*] --> C
    [*] --> D
    B --> A
    C --> A
    D --> A
Diagram illustrating the "fan-out" configuration.

Here we successfully decoupled B, C and D so that they can run in parallel, which is good. But they all depend on A so that each change in A determine that every other module is affected and must be recompiled.

Modularizing in the correct way allows to minimize the occurrences of situations like these. There is a consensus among mobile developers that modularization by feature is the best one to avoid unwanted entanglements between different parts of the app, rather than isolate modules by layer.

However, each project has its needs and each developer has their opinion, so a balance needs to be found. In Raccoon, I decided to divide projects in three kinds:

  • core modules: reusable pieces of software that provide the foundational layer for all other ones. Each of them should not depend on any other module ideally, but if it really has to, it can only be on some special core modules with the least amount of outgoing edges (e.g. :core:di, :core:l10n or :core:preferences). They are divided logically by the functionality they provide (except :core:utils).
  • domain modules: contain the model and business logic and are roughly divided by area, e.g. the classes related to identity, the ones related to remote contents, and so on. Here the criterion is more layer-centered than feature-centered (e.g. content-related data models are in a single module) but for practical reasons, because application logic and domain models are not changing so frequently in this kind of project. Domain modules can depend only on core modules.
  • feature modules: contain the presentation logic and they are strictly divided by feature. As a matter of fact, there is almost a 1:1 relationship between each subproject and each app screen. Feature modules can depend on core and domain modules, but not on any other feature.1

Convention plugins

Splitting a project into multiple subprojects comes with some configuration overhead. Each subproject needs its build script with its applied plugins, its dependencies, its configuration. Some parts of the build scripts are common to all modules, some are "more equal" than others (e.g. all modules having a UI need the Compose compiler plugin applied and include Compose dependencies), etc.

For external library versions, there is an out-of-the-box solution already offered by Gradle: Version Catalogs which allow to centralize in a single source of truth all the dependency versions.

The solution I decided to adopt for common configuration is known as Convention Plugins, i.e. isolating the repeated bits of build configuration – e.g. applying some Gradle plugins or including some dependencies which always go together or configure a plugin in some way e.g. for running tests.

This way it is simpler to make changes, because you have to only change the definition of the plugin to automatically adapt dozens of subproject in a row; which was extremely beneficial in situations like the AGP 9.x migration.

The main custom plugins I wrote are:

  • com.livefast.eattrash.kotlinMultiplatform to be applied in all modules to apply and configure the org.jetbrains.kotlin.multiplatform and com.android.kotlin.multiplatform.library plugins;
  • com.livefast.eattrash.composeMultiplatform to be applied in all UI related modules to apply and configure the org.jetbrains.compose and org.jetbrains.kotlin.plugin.compose plugins;
  • com.livefast.eattrash.test and com.livefast.eattrash.uiTest to configure host tests and device tests.

Wrap-up

I consider that the ability to spot and avoid pitfalls is crucial in order to maintain a high quality of developer experience, not only user experience; and working on Raccoon one has been a great training ground which allowed me to become a better developer for my work IRL.

Identify common parts (even in configuration, not only in logic) and factor them out to reusable pieces of code is an application of the DRY principle; it saves a lot of time which can be dedicated to work on more valuable activities.

Plus, it taught me not to "fear" Gradle and better understand the tools I use in my everyday life, even at work and not only in side projects.

Question

What is your experience with Gradle? Did you come to similar solutions or do you have futher recommendations? Let me know!


  1. This requires some careful design for navigation, because navigation between screens must be totally decoupled. In Raccoon I used a :core:navigation module and abstracted away the underlying navigation system (I actually migrated from Voyager to Compose Navigation and I am willing to migrate to Navigation 3 soon). ↩

From mobile to desktop: our journey towards a new platform

Premise

KMP is a remarkably flexible and unintrusive technology: it allows you to choose exactly which code to share between platforms without forcing any specific architectural patterns on you.

We've discussed its key advantages and why it's a compelling choice for building Fediverse apps in a previous post.

Self-deception

However, Raccoon has always been highly Android-centric. For a long time, I targeted only one platform, tested only on Android, and distributed only via APKs and app bundles. iOS-specific code was written where necessary during development, but it often felt like a shot in the dark.

Occasionally I would run a debug build on an iOS simulator just to gaslight myself into believing this was a truly multiplatform project, even when all factual evidence pointed to the contrary.

Reality check

But sooner or later, the moment to face reality arrives. For me, it came when I was invited to speak at this year's DevConf 2026 about Raccoon. I only had one issue: the app had mostly seen bug fixes and maintenance over the last year. As I was heading toward a stable 1.0 release, I lacked "cool and shiny" new features to showcase at a conference.

Then it hit me: I could finally fulfill the requests from my Linux users and build a desktop version. This would allow me to present not just a mobile app, but a cross-platform open-source client for the federated social web.

I decided to dedicate a weekend or two to porting the app, promising to pivot if it didn't feel worth the effort. To my surprise, after just one night of work, I had a macOS app running. It was missing features and the layout was… questionable, but it worked and this was enough to give me fuel to continue the work!

Spoiler

We eventually decided to switch topics for the conf (stay tuned!) but Raccoon will still be making an appearance!

Our journey towards a new platform

In this article, I'll share my journey of supporting a new platform, highlighting the most critical hurdles I encountered. Here's what we'll cover:

  • Build configuration
  • Adding an entry point
  • Native implementations
  • The OAuth2 Challenge

Build configuration

This boiled down to configuring the org.jetbrains.kotlin.multiplatform Gradle plugin for a new target. Luckily, Raccoon uses convention plugins (more on this in a future post). Since I already had acom.livefast.eattrash.kotlinMultiplatform plugin, I only had to add jvm() within a single extension function to automatically update over 50 subprojects.

Admittedly, there's a bit more to it since I'm also using CMP. Similarly, I updated my com.livefast.eattrash.composeMultiplatform convention plugin to adapt the UI layer across all affected subprojects.

Adding the entry point

Every Java-based application needs an entry point. I initially created a main() function in the :shared subproject, but later moved it to a dedicated :desktopApp module. This aligned the project structure with the new JetBrains defaults, which we discussed in a previous post.

Native implementations

This is where I spent the bulk of my time: adding actual versions for all expect declarations.

Sometimes this was trivial because I used libraries that already offered JVM support, such as:

Other times, it was difficult because no JVM equivalent existed for specific mobile functionalities (like Moko-permissions or UnifiedPush).

Even seemingly simple tasks turned out to be tougher than expected. For instance, reading app metadata (version and build number) is intrinsically coupled with how the application is packaged for each specific platform.

The OAuth2 Challenge

The OAuth2 login flow deserves its own spotlight. Typically, you redirect the user to an external provider in a webview, they authenticate, and then redirect back with a code you exchange for a token.

On mobile, this relies on deep linking (custom URI schemes) so the OS knows to pass the data back to your app. Web apps just point back to a backend server.

But what about desktop apps? I initially tried using the Calf webview, but it struggled with intercepting the redirects needed for the OAuth2 flow.

The solution? I leveraged Ktor — already in use for network calls—to spin up an ephemeral local server. The redirect URI points to localhost on the first available port. The server stops as soon as the request is intercepted or after a short timeout. It turned out to be an elegant and seamless solution.

Conclusion

This journey was incredibly enriching. It pushed me to improve the layout for larger screens, which—as a side benefit — greatly improved the experience for mobile users on tablets and foldables.

While it might seem like a small addition, providing a .deb package for every release is a win for the open-source community. Having a dedicated desktop Fediverse client helps the ecosystem grow, and I'm excited to see where the project goes next.

Dealing with KMP’s Shifting Sands: Another Day, Another Project Structure

Hurray! A few days ago, JetBrains's blog published a new post highlighting the new default structure for KMP projects, which goes hand in hand with the migration to AGP 9.x.

This piece of news hit me last Friday evening. I had just left the office after a long, tiring work week. I got into my car to drive home and opened YouTube, hoping to listen to a relaxing talk during my commutetime… and voilà! There was Márton Braun uttering the frightening words: «We are introducing a new default structure for KMP projects…»

And so there I was — suffering from framework-induced PTSD, nervously blinking and hysterically chuckling alone in my car. I remembered how it took me several days to get my local builds for all platforms, CI workflows, and integrations (Codecov and Weblate, just to mention a couple) up and running correctly.

How on earth is the recommended structure changing again?!

Backstory: breaking changes in AGP 9.0

To fully understand my despair, a little bit of background is needed.

Earlier this year, I migrated from Gradle 8.14.x to Gradle 9.1, and from AGP 8.11 to AGP 9.0.0.

For KMP projects like mine, this meant it was no longer possible to apply the org.jetbrains.kotlin.multiplatform Gradle plugin alongside the com.android.library or com.android.application plugin in the same module.1

Instead, the newcom.android.kotlin.multiplatform.library plugin was introduced. Now, all shared modules must use the new KMP library plugin, while the main Android entry point has to use com.android.application alone.

The previous approach — where all entry points (except iOS which was in a separate folder in order to be opened as an Xcode project) coexisted in a single composeApp module — was no longer viable.

Therefore, I had to create a shared library module, making the Android application a separate module entirely. Ironically enough, this feels a lot like the standard structure we had back in 2023.

I felt relatively lucky because only my Raccoon for Friendica app used the structure they recommended after 2023. My Raccoon for Lemmy app still had a shared module and a separate androidApp module.

But the story doesn't end there.

Ripple effect: broken tests and displaced resources

AGP 9.x also fundamentally changes the source set structure for common unit tests (running on the host system) and instrumented tests (running on a device/simulator).

In my case, I used convention plugins, meaning I apply configurations via the Gradle Kotlin DSL and various plugin DSLs rather than doing it manually.

Trying to figure this out with incredibly scarce and scattered documentation on how to do it properly was a nightmare (and I had to fix it (e.g. here and here).

Not to mention that all Compose resources such as string translations had to be moved from the composeApp mobile module to the shared module, which completely broke my crowdsourced translations on Weblate.

A love-hate relationship

I am truly caught in a love-hate relationship with KMP right now. More than ever, it feels like a brittle foundation to build a long-term project upon.

«Why are you complaining?» you might ask. «After all, early-adoption has always been like this.» And you're right. In the early years, this chaos was expected. Every new technology needs time to settle down until the community reaches a consensus on best practices and shared conventions.

But KMP has been around for quite some time now, and it is supposed to be stable! Yet here we are, and it feels like they introduce breaking changes every six months.

Looking ahead

So here I am once again trying to follow their new standard as closely as possible. I'm doing this in the desperate hope of minimizing the chances of everything melting down with the next release of the Kotlin compiler, KSP, Gradle, AGP, Compose, or whatever else updates next.

At the very least, I’m hoping that by strictly adhering to these recommendations now, the next migration will be less painful. It feels like offering sacrifices to a vengeful deity, praying that my compliance today will earn me a little more mercy when the next storm hits.

As a conclusion, I hope you allow me this little rant. Also, please wish me luck—because as of right now, I have no idea if my iOS build is actually working, and I won't know if any of my release workflows are truly safe until my next beta deployment.


  1. A note on terminology: in the rest of this article, the word "module" is used as a synonym of subproject for brevity, even though in the Gradle lingo the most correct term is "subproject". ↩