Skip to content

2026

From side project to Procyon: a look behind

As we are approaching the last quarter of the year, it is normal for me to look back at all that has been done in the last months, evaluate the current state of the project and plan for the future.

Live fast, and eat trash (a.k.a. reuse code)

The Raccoon client for Mastodon and Friendica was started during the summer of 2024, as a spin-off of the Lemmy client, whose development had started approximately a year before. The initial core was assembled in a short time, taking heavy inspiration from the already established app, trying to reuse as much as possible of the existing code.

The strategy was, more or less: take the existing architecture, switch the APIs to be compatible with Mastodon/Friendica instances, rewrite the content rendering engine (since Lemmy posts were Markdown-based while Mastodon posts are HTML-based), distribute it fast and see how it is received by the public.

There were minimal variations in the tech stack, e.g. using Room Multiplatform instead of SQLDelight for local persistence, using Sentry for crash reporting (instead of saving traces on device and rely on manual reports), using Mokkery in commonTest instead of MockK on androidHostTest.

KMP in its infancy: the technical debt

As a result, large portions of technical debt inherited from an era when KMP was immature were still sitting there:

  • localization used Lyricist (instead of the CMP resource system);
  • DI using Koin with classic DSL (despite annotations being available);
  • Voyager navigation (despite Compose Navigation being ported to multiplatform);
  • endpoint calls used Ktorfit (breaking at every KSP update);
  • setting encryption still used deprecated EncryptedSharedPreferences on Android;
  • build configuration was scattered in individual scripts across subprojects, with subsequent duplication and potentially subtle inconsistencies.

Paying it back: modernizing the stack

Starting from the end of 2024 and for most of 2025, besides reaching feature parity with existing clients and fixing bugs, I always allocated a great amount of time to evolve the project and pay off that debt.

At the beginning of 2026, the situation was the following:

  • resources and localization had been ported to the built-in CMP system, but icons still relied on Google's Material Icons set, which has been deprecated in the meantime;
  • DI was ported to Koin with KSP annotations, breaking reproducible builds, so Kodein was introduced as a temporary replacement until a more modern solution became available;
  • the navigation system was ported to Compose Navigation with regular AndroidX ViewModels;
  • network calls were implemented with plain Ktor, no need for Ktorfit adapters;
  • a custom layer for preference encryption on Android was introduced;
  • build logic was centralized in a set of convention plugins, consistently applied throughout all subprojects for configuration.

Breaking changes and the road to 1.0

In the meantime, Gradle 9 and AGP 9.x were released, making the project's very structure and most of the build logic obsolete.

Therefore, a series of tough decisions had to be made in order to keep the project "healthy" and release a stable 1.0.0 version:

  • revamp the project's website and documentation to get a more modern and functional look (using Zensical);
  • update Gradle and AGP, aligning with the now recommended project structure;
  • add the JVM target and introduce desktop (with .deb package published at every stable release);
  • introduce support for tablets and large screens using adaptive layouts;
  • replace Material Icons with Material Symbols.

And that was exactly what I prioritized for version 1.0.0, which was released on June 8th, 2026.

Beyond 1.0: surfing the KMP bleeding edge

After the first stable release, I started working on the areas which still needed improvement, such as:

  • Navigation 3 with the concept of Scene and SceneStrategy offers a much more elegant solution to the problem of adapting navigation and screen layout to the available screen size;
  • a new player entered the DI scene on KMP: Metro, which finally promises to solve all the issues of previous solutions: conciseness, compile-time safety, power and flexibility;
  • a new Preview annotation to be used in common code was introduced, making setup easier and cleaner.

Those were essentially the areas where I have been working in the last months, alongside a lot of code cleanup and keeping an "aggressive" update strategy for Kotlin versions (adopting new features as they emerged, e.g. explicit backing fields or the unused return type checker).

New features were added as well, in the meantime, such as:

  • support for quote posts on Mastodon;
  • content translation with LibreTranslate;
  • implementing cross-instance exploration;
  • adding themes for the reply bar in forum view;
  • improve search and suggestion functionality with the transition to /v2 endpoints.

What now? A look ahead

What can be expected for the immediate future, then? First of all, I finally feel that I have a solid foundation to build upon, thanks to the modernization effort I've engaged in.

For sure, on the feature side, support will be added for new Mastodon 4.6 features, such as user collections.

On the UX side, I'd like to leverage some new features of CMP (e.g. Grid) for better attachment rendering (instead of the existing carousels).

Finally, tech-wise, I am looking forward to the moment when rich errors are introduced in the language.

In the meantime, I still have to adopt other language features already in preview, like name-based destructuring. Or, since androidx.lifecycle:lifecycle-*:2.11.0 (and its multiplatform port) introduced Scoped ViewModels, this will be the occasion to isolate responsibilities and trim some VM which had grown too large.

Rekindling the spark: moving Raccoon to Metro

"For now" was doing a lot of heavy lifting

The last article ended with the words "you can consider this the end, for now, of my concerns about DI."

To be honest, when I wrote that sentence, I was far from happy with the state of things. That was precisely why I cautiously tacked on that parenthetical "for now".

The cracks in KCP

Between mid-July and early August, I had, in chronological order:

  • Migrated Raccoon from Kodein back to Koin out of genuine concern for the future of KOSI and the libraries they maintain;
  • Written a convention plugin to factor out common DI configuration across subprojects (adapting 50+ modules in the previous step made me want to make future work easier on myself);
  • Evolved from the "classic DSL" style to the Koin Compiler Plugin (KCP) with annotations, aiming to reduce manual wiring to a minimum and gain compile-time validation.

This was a substantial amount of work — with not a single visible benefit for end-users — and it forced me to freeze all new feature development and enter "maintenance mode," sticking strictly to bug fixes, dependency updates, and code cleanup.

What made it worse was that I wasn't even 100% satisfied with the end result. These were the issues that nagged at me most:

  • Kotlin 2.4.10 incompatibility: KCP wasn't fully compatible with the most recent version of Kotlin, emitting warnings for every module on every build;
  • iOS false positives: compile-time validation triggered false positives on iOS that broke iOS builds entirely, forcing me to disable it (and configuring that conditionally based on build target inside a Gradle convention plugin was anything but straightforward);
  • False negatives and runtime crashes: I realized validation was also yielding false negatives, meaning I could have a perfectly compiling project that still crashed at runtime due to migration oversights, e.g. marking a parameter with @InjectedParam (in either a factory function or a constructor) and then forgetting to pass it at the call site.

Rekindling the spark: Why Metro?

In 2026, there had to be something better in the KMP ecosystem. That's when a tiny voice in my head whispered: "Have you considered switching to Metro?"

I've been doing Android development for longer than I'm comfortable admitting (though my silver hair and beard betray me). I'm quite well-versed in Dagger/Hilt on native Android, and I'd been following the Metro project with keen interest—mixed with equal parts hope and apprehension.

I had spent months reading Zac Sweers' documentation, articles, and posts on the kotlinlang.org Slack, listening to all his talks. I'd been dreaming that one day I could convert Raccoon, while simultaneously worrying about how long and painful such a migration might be.

Last weekend, I started a playground project to test out the framework, evaluate its setup complexity and reproduce the specific edge cases I knew Raccoon presented. Then the work week hit, and it was a tough one. I had to churn through a series of difficult and remarkably unrewarding tasks.

After a couple of days of grinding, I decided I needed to compensate by building something that rekindled that "little spark": the feeling every developer gets when they finish a piece of work and are genuinely satisfied with the outcome.

So, one evening, I took the plunge and started the Metro migration.

Rolling up my sleeves: the migration plan

The following section isn't meant to be a general tutorial on how to use Metro: the project already has excellent documentation, especially if you're already familiar with Dagger/Hilt, kotlin-inject, or Anvil.

Instead, this is a chronicle of my personal approach to migrating Raccoon's specific codebase.

Given its multi-module architecture, I mapped out a six-step plan:

  1. Adapt the build logic and plugin setup;
  2. Define the root graph and entry points;
  3. Migrate core infrastructure;
  4. Migrate the business logic;
  5. Migrate all feature modules;
  6. Final cleanup.

Step 1: Build logic and plugin setup

This step involved creating a Gradle convention plugin to factor out the common DI configuration across all subprojects. The convention plugin applies the dev.zacsweers.metro plugin (version 1.4.3) and provides an extension option to also add the dev.zacsweers.metro:metrox-viewmodel-compose dependency when needed.1

Step 2: Defining the root graph and entry points

Next came defining the RootGraph interface in the :shared subproject, exposing essential accessors like UiDeps (crucial for UI-related dependencies) and AuthManager (required for the OAuth2 login flow):

interface RootGraph : ViewModelGraph {
    val authManager: AuthManager
    val uiDeps: UiDeps
}

Note that this interface is not annotated with @DependencyGraph, because the actual concrete graphs are platform-specific:

// in :androidApp
@DependencyGraph(scope = AppScope::class)
interface AndroidRootGraph :
    RootGraph,
    MetroAppComponentProviders,
    // exposes deps to the PushService subclass
    PushNotificationComponent,
    // exposes deps for the pull notification Worker
    PullNotificationComponent {

    @Provides
    fun provideContext(): Context = MainApplication.instance
}

// in :desktopApp
@DependencyGraph(scope = AppScope::class)
interface DesktopRootGraph : RootGraph

// in the iosMain source set in :shared
@DependencyGraph(scope = AppScope::class)
interface IosRootGraph : RootGraph

The main @Composable entry point of the application was adapted to accept RootGraph as a parameter.

On the platform sides:

  • Android: MainApplication was updated to invoke the createGraph factory inside onCreate(), while MainActivity was updated to retrieve the root graph reference from the Application instance, use it for the OAuth2 login flow, and pass it down into the Composable tree.
  • JVM (desktop): The main() function was updated to call the createGraph factory and pass the resulting graph down into Composition.
  • iOS: MainViewController was updated to lazily call the createGraph factory (passing it into Composition), and iOSApp was adjusted to access AuthManager via MainViewControllerKt.iosRootGraph for OAuth2 authentication.

Step 3: Migrating core infrastructure

In this phase, all subprojects under the :core group were migrated, focusing primarily on remote API services, local persistence, and native utilities. While I won't detail every single class, a couple of key highlights illustrate the flavor of the migration.

Remote API services relied heavily on assisted injection. The centralized factory, which previously relied on Koin's dynamic runtime parameter resolution based on class types, was refactored to use explicit pattern matching over types.

Before:

@Factory
internal class DefaultAppService(@InjectedParam args: ServiceCreationArgs) : AppService {
    /* ... */
}

@Single
internal class DefaultServiceFactory : ServiceFactory, KoinComponent {
    override fun <T : Any> create(clazz: KClass<T>, args: ServiceCreationArgs): T =
        getKoin().get(clazz = clazz, qualifier = null, parameters = { parametersOf(args) })
}

After:

@AssistedInject
class DefaultAppService(@Assisted args: ServiceCreationArgs) : AppService {
    /* ... */
}

@AssistedFactory
fun interface AppServiceFactory {
    fun create(@Assisted args: ServiceCreationArgs): DefaultAppService
}

@ContributesBinding(AppScope::class)
@Inject
class DefaultServiceFactory(
    private val appServiceFactory: AppServiceFactory,
    /* ... other factories... */
) : ServiceFactory {

    override fun <T : Any> create(clazz: KClass<T>, args: ServiceCreationArgs): T {
        val service = when (clazz) {
            AppService::class -> appServiceFactory.create(args)
            /* ... remaining `when` branches with other service types... */
            else -> throw IllegalArgumentException("Unknown service class: ${clazz.simpleName}")
        }
        return clazz.cast(service)
    }
}

I intentionally refrained from migrating @Single to @SingleIn(AppScope::class) here because doing so would be redundant: ServiceFactory is injected only once in the entire project inside ServiceProvider (which itself is a singleton). Furthermore, even if it were injected elsewhere, leaving it unscoped offers distinct advantages:

  • It is a lightweight, stateless object, so instantiation overhead is negligible;
  • It holds no internal state, eliminating concurrency or interference risks;
  • Unscoped instances can be garbage collected as soon as they go out of scope, rather than lingering permanently in memory.

Tip

As a general rule during this migration, leaving dependencies unscoped was always the preferred default whenever possible.

Here is the exact decision heuristic I used to decide whether to scope a dependency or keep it unscoped:

Is the class a ViewModel?
  ├── YES ──> ✅ Use @Inject + @ContributesIntoMap(AppScope::class) with @ViewModelKey
  └── NO ──> Does it hold in-memory state or flows shared across components?
              ├── YES ──> ✅ USE @Inject + @SingleIn(AppScope::class)
              └── NO ──> Is it heavy or expensive to construct?
                          ├── YES ──> ✅ USE @Inject + @SingleIn(AppScope::class)
                          └── NO ──> ❌ DON'T scope ✅ USE unscoped (just @Inject)

Step 4: Migrating the business logic

This step involved migrating all subprojects in the :domain group, which went smoothly without major surprises. The key detail was preserving the singleton scope (@SingleIn(AppScope::class)) for swipe navigation state while keeping newly provided pagination instances unscoped.

In Raccoon, the domain layer also handles push and pull background notifications on Android. As mentioned in Step 2, the dependencies needed inside our background Worker were previously retrieved via Koin and are now exposed through PullNotificationComponent.

Before:

@KoinWorker
internal class CheckNotificationWorker(
    context: Context,
    parameters: WorkerParameters,
    // injected by the framework
    private val inboxManager: InboxManager,
    private val strings: Strings,
) : CoroutineWorker(context, parameters) {
    /* ... */
}

After:

internal class CheckNotificationWorker(
    context: Context,
    parameters: WorkerParameters
) : CoroutineWorker(context, parameters) {

    private val component: PullNotificationComponent
        get() =
            (applicationContext as? MetroApplication)?.appComponentProviders as? PullNotificationComponent
                ?: error("PullNotificationComponent not found")

    private val inboxManager: InboxManager by lazy { component.inboxManager }

    private val strings: Strings by lazy { component.strings }

    /* ... */
}

Step 5: Migrating all feature modules

This group of modules houses UI and presentation logic. For the UI layer, the UiDeps accessor (providing access to UI helpers like NavigationCoordinator, MainRouter, DrawerCoordinator, ClipboardHelper, etc.) introduced in Step 2 finally came into action.

In the old setup, many of these dependencies were fetched directly inside navigation containers using koinInject(). That pattern is typical of service locators, but in anticipation of the Metro migration, I had already minimized those calls. Now, after retrieving UiDeps from RootGraph, it is passed down through child composables via a CompositionLocal.2

As for presentation logic, ViewModels are the star of the show, and the metrox-viewmodel-compose artifact made migrating them surprisingly straightforward.

Many ViewModels take assisted constructor parameters (e.g., navigation arguments). We already saw how @AssistedInject works in Step 3; the only new requirement here was subclassing ViewModelAssistedFactory and annotating it with @ViewModelAssistedFactoryKey:

@AssistedInject
class LoginViewModel(
    @Assisted args: LoginViewModelArgs,
    /* ... all non-assisted injected params... */
) : ViewModel() {
    /* ... */

    companion object {
        val KEY_ARGS = CreationExtras.Key<LoginViewModelArgs>()
    }
}

@Serializable
data class LoginViewModelArgs(val type: LoginType)

@AssistedFactory
@ViewModelAssistedFactoryKey(LoginViewModel::class)
@ContributesIntoMap(AppScope::class)
interface LoginViewModelFactory : ViewModelAssistedFactory {
    override fun create(extras: CreationExtras): LoginViewModel =
        create(extras[KEY_ARGS] ?: error("ViewModel args not found"))

    fun create(@Assisted args: LoginViewModelArgs): LoginViewModel
}

At the call site, assistedMetroViewModel() serves as a near drop-in replacement for koinViewModel(), with arguments passed through CreationExtras:

val model = assistedMetroViewModel<LoginViewModel>(
    extras = CreationExtras {
        this[KEY_ARGS] = LoginViewModelArgs(loginType.toLoginType())
    },
)

The non-assisted case, on the other hand, is even simpler:3

@ContributesIntoMap(AppScope::class)
@ViewModelKey
@Inject
@OptIn(FlowPreview::class)
class TimelineViewModel(
    /* ... injected params... */
) : ViewModel() {
    /* ... */
}

Where at the composable call site, it boils down to:

val model = metroViewModel<TimelineViewModel>()

Step 6: Final cleanup

This was by far the most satisfying part: stripping out all Koin references, cleaning up the version catalog, and deleting the DI setup helper object where all Koin @Modules were previously registered by hand.

The battle scars: Top technical blockers

Unsurprisingly, when migrating a medium-sized project with 50+ modules and 150+ injected dependencies, not everything worked perfectly on the first attempt. Here is a breakdown of the mistakes I made along the way—along with their symptoms, root causes, and fixes.

1. Multi-module Gradle dependency scope

  • Symptom: Compile-time error ([Metro/MissingBinding]) or, worse, runtime IllegalArgumentException when building :androidApp.
  • Cause: :shared included feature modules using implementation(projects.feature.timeline). In Gradle, implementation isolates transitive dependencies. Metro's @DependencyGraph processor running in :androidApp couldn't discover @ContributesBinding/@ContributesIntoMap in feature modules because they were missing from :androidApp's compilation classpath.
  • Solution: Replace implementation with api in shared/build.gradle.kts for all feature and domain modules.

2. Visibility modifiers and cross-module bindings

  • Symptom: Compile-time error ([Metro/MissingBinding]).
  • Cause: Classes annotated with @ContributesBinding(AppScope::class) were declared as internal. Metro generates internal bindings that the root graph in platform-specific modules ( :androidApp or :desktopApp) cannot access across Gradle module boundaries.
  • Solution: Remove the internal visibility modifier from @ContributesBinding-annotated classes.

3. ViewModel multi-bindings with multiple supertypes

  • Symptom: Compile error: @ContributesIntoMap-annotated class doesn't declare an explicit binding type but has multiple supertypes.
  • Cause: ViewModels that extended ViewModel() and also implemented MVI contract interfaces had multiple supertypes, creating ambiguity for Metro's implicit supertype resolution.
  • Solution: Explicitly pass the binding parameter and use @ViewModelKey inside the generic type argument e.g. binding<@ViewModelKey ViewModel>() (leaving the interred class key).

4. Swift / KMP Framework export boundaries

Last but not least, a KotlinNativeTarget configuration issue!

  • Symptom: Swift compilation error: cannot find 'X' in scope or type 'MainViewControllerKt' has no member X.
  • Cause: Swift only exposes types from packages explicitly listed in the framework's export declaration. Top-level functions in non-exported files or Kotlin objects outside exported packages are omitted from the generated Swift header.
  • Solution: Ensure the KMP framework is configured with the correct export(...) directives and that :shared exposes its transitive dependencies via api ( see Blocker 1).

  1. The dev.zacsweers.metro:metrox-android dependency was added manually to the :androidApp module. ↩

  2. This was also a great opportunity to refactor our CompositionLocal providers and streamline the "startup ceremony" required for UI components (making UI tests and Compose previews much cleaner). ↩

  3. In my case, it was slightly more involved because in my MVI setup all ViewModels also implement another interface, and the UI refers to them through that interface type. See Blocker 3 for a more detailed explanation. ↩

Koin is back, b*tches!

If you remember, last summer I wrote a technical article about DI and why our Raccoon apps, which had always used Koin from the beginning in 2023, were both migrated to Kodein at the end of 2024.

TL;DR

There was an issue with the 2.0.0-Beta version of Koin Annotation which broke reproducible builds, which blocked releases on F-Droid. I reported the issue but it remained unanswered and unnoticed for months.

An unwanted Christmas present

A year and a half later, I still do not have good memories of that migration. I completed it around the last week of December, and it was a bitter Christmas present. It just felt "wrong" in many ways.

In the first place, Kodein was not on the same level of completeness, and I had to manually write some missing connectors, e.g. for lifecycle-viewmodel integration. Secondly, I had to switch from a powerful annotation set to define bindings to manual wiring, just as with Koin's classic DSL.

So I was left with all the advantages and disadvantages of a classic DSL-based service locator (which apply both to Kodein and to Koin with the classic DSL):

Feature
Multiplatform support ✅
Flexibility (definition and call site) ✅
Conciseness ❌
Compile-time validation ❌
Performance ❌

Yes, it is true that this works seamlessly across all supported platforms (even if, as usual, the iOS build was more complicated). It is also easy to integrate and flexible (perhaps excessively so, considering it's possible to retrieve a dependency at whatever level of the architecture). On the downside, manual wiring via DSL implies boilerplate, there is no compile time safety so missing bindings result in runtime crashes and, last but not least, there's some overhead since resolution only happens at runtime.

I could accept this as a temporary tradeoff in order to have reproducible builds back, but I was keeping an eye on the Koin library evolution, waiting for the right moment to come back. And, as of 2026, that moment seems to have finally arrived.

"it is with great pleasure to inform you" frog meme

But, this time, it's done the "right way".

Why a change was needed

Technical considerations

As anticipated, there were some compromises. I had to accept maintaining a fair amount of boilerplate (scattered along 50+ module definitions per project), because no matter how well-thought DSLs are, you still have to manually wire components, i.e.:

  • Scoping: define the lifetime of each component and scope it correctly;
  • Binding: provide implementors using the qualifiers provided by the framework (typically a combination of type and names/tags);
  • Assisted injection: deal with the cases when some constructor parameters are dynamically passed at runtime, while some others are provided by the framework itself.

I also had to give up on safety, i.e. not get compile time errors if the DI setup is incorrect, e.g.:

  • Missing bindings: some dependency is used somewhere, but it is defined nowhere;
  • Conflicting bindings: there are multiple dependency definitions with the same qualifiers, leading to resolution ambiguity;
  • Cycles: the DI graph needs to be acyclic, it is not possible to have direct or indirect recursion, i.e. it is not possible to require in order to instantiate a component anything requiring the component itself (potentially across multiple indirection levels)

Finally, there were some performance tradeoffs I had to accept: using the service locator pattern implies that dependencies can only be resolved at runtime, with some inevitable overhead.

While these issues affect both Kodein and Koin, the latter has been maturing year after year and is going towards a different direction, whereas the former has been stalling. Which leads towards a second set of reasons why I was considering a change.

Political considerations

There were increasingly worrying signals about how the KOSI project is maintained, detailed in the public Manifesto.

  • Development Bottleneck: core maintainers operate as a commercial agency and explicitly shifted focus, reducing their open-source bandwidth;
  • Ecosystem Stagnation: Secondary projects have been frozen rather than community-delegated.1
  • Strict Governance: the organization acts as a closed ecosystem to protect the corporate brand. Community pull requests are accepted only in isolation, and the project does not onboard independent co-maintainers (applications are denied in spite of workforce being needed to implement feature requests).
  • Architectural Stalling: The project maintains an intentional distance from modern compilation features, such as compile-time verification.

Salomon Brys, one of the two core maintainers, publicly stated:

we believe that compile-time verification is way to [sic] restrictive and leads to a lot of complications when we want to provide flexibility

This sets them apart from the current industry trend towards increased compile-time safety.

The migration steps

In our Code of Conduct, the last bit says "Never give up". Even when this implies taking difficult decisions. Every technical challenge is an opportunity for improvement, experimenting and having fun.

Sometimes development, like life, moves in spirals rather than in a straight line and in order to advance further a phase when it seems like you are going backwards may be required.

Tip

In order to better understand the following, remember that DI is like a two-side coin (koin?):

  • Definition Site: where each component is scoped and bound;
  • Call Site: where a component is required and accessed in client code.

The first step was migrating back from Kodein to the initial "classic DSL" Koin, where each use case had almost a one-to-one equivalent2 at least at the definition site.

Secondly, I worked on cleaning up all the cruft, i.e. removing the "glue code" I had to write to make up for what Kodein was lacking (Compose integration, ViewModel integration etc.), which mostly was on the call site.

In the third place I migrated from the "classic DSL" to the new Compiler Plugin (KCP) DSL at the definition site, which already added compile-time validation and performance: all dependencies were determined and validated at compile time.

As a fourth step, again at the definition site, I migrated to annotations, in order to remove all boilerplate and leverage component scanning and automatic wiring (e.g. when a class implements only an interface the binding is automatically done).

Final outcome

The result has the best of both worlds: the flexibility of a service locator, the safety of a full-fledged DI framework.

Feature
Multiplatform support ✅
Flexibility ✅
Conciseness ✅
Compile-time validation ✅
Performance ✅

Moreover, Koin has established itself as the leading framework for DI on KMP. It has extensive documentation and tooling, and it is backed by an active group of users and maintainers and has grown over time, showing maturity and openness towards community feedback.

Lessons learned

I tend to be open-minded towards tools and I chose to use libraries like Koin and Kodein no matter how much they were frowned upon by my colleagues as professional Android developers (accustomed to Dagger/Hilt safety and power). However, I remember having thought my coworkers were probably right when the reproducibility problem hit.

I remember how angry I was when one single library was acting as a road blocker and preventing updates from being distributed on app stores, and I swore I would never go back to Koin in my life.

But eventually I appreciated the commitment of Arnaud and his team; I listened to him in person at KotlinConf some year ago, which was really impressive. I was really grateful for their listening to community feedback, abandoning the old KSP-based approach in favor of the new KCP.

Changing one's mind is a sign of intelligence and maturity, and I was genuinely happy to change my mind with this respect.

You can consider this the end, for now, of my concerns about DI in Raccoon.3


  1. For example, the official Kodein-DB repository has been explicitly labeled as a "Project paused" with the caveat that it would not be maintained in its current form. ↩

  2. Except that Kodein has three ways of defining bindings: singleton (single instance), provider (new instance each time, no arguments) and factory (new instance each time with assisted arguments); whereas Koin has only two: single and factory (with or without arguments). ↩

  3. As told in the previous article, Metro looks great, but I am still evaluating it, considering both technical features and the way is maintained. ↩

The Intentional Raccoon: Why I Code the Hard Way

Do you remember the reaction my admin friend got when they published a post about Raccoon's 1.0.0 release on lemmy.world?

Lemmy users will be Lemmy users: radical, aggressive, self-referential. I tried to work "for" them in the past and create a client app, but for my IRL mental sanity I had to take several pauses over time, and now I am considering archiving that project altogether.1

Let alone the attitude, let alone that the OP was accused of being a bot for using a human expression like "finally", the concerns expressed are real and shared by more and more people.

The Reverse Turing Test

As the Internet is being flooded by AI-generated content, some people are becoming paranoid about "AI slop". It seems like they have nothing better to do than Turing-test everything to detect any non-human footprint and, if they do, they start throwing stones, crying about techno-feudalism, spread alarmism about electricity or water consumption, etc.

What made me most sad about this episode is that the person who was accusing didn't spend any time trying to understand who he was talking to and what he was talking about. So, here is my attempt to clarify who I am and what my personal position is on AI usage in OSS development, especially as side-projects are concerned.

A Choice of Freedom

I got a university degree in CS. I've been working in IT for fifteen years at several different companies. I've been experiencing on my own skin what being into the "meat grinder" feels like, long before 2022 when ChatGPT promised to solve all mankind's (and programmers') issues.

At work, I have to deal with unrealistic deadlines (not decided by me), maintain legacy code ( written by people who left the company), adhere to conventions or obey someone else's decisions ( I don't fully share).

Even when, how much and from which provider to use AI is not my decision from 9 to 6, when I sit at my desk in the office.

Side projects have always been an "escape way" to me. When I come home (in evenings and weekends) and open my IDE I have a totally different approach. I am free to experiment, scout new technologies I like, learn things I choose, set my own schedule and priorities without checking the clock, write and organize code according to my personal taste. There's more, usually I solve real problems I experience in my life in first person.

There is something deeply personal in that. It makes me feel the joy of creating something with my commitment, week after week, like growing a tree. It makes me feel a sense of achievement whenever I implement a feature request or fix a bug, which is greater the more difficult it was to get to the solution, like in a videogame (quest - reward).2

To be honest, developing "at my pace" also has a nostalgic bit to it: it reminds me of an era when developing software still had some "craftsmanship" in it, when I was younger and the future still seemed bright.

If I vibe-coded my apps, what would I get? I would give up on all creativity. The "videogame" dynamic would almost be gone. I wouldn't learn anything which enriches me as a person and as a professional.

Assembly vs. Craft

Working in my 9-to-6 job has always felt like being in an assembly line. You clock in, the machine starts grinding, you have to follow the rhythm, there's no questioning. Efficiency (reduce time to save company's money), functionality (adhere to requirements someone else decided), predictability (enforce standards and prioritize repeatability) are key.

But we all remember «Modern Times» by Chaplin, that is alienating. I am not saying that in my Raccoon apps everything is handmade, I use several third-party libraries and I didn't reinvent the wheel for networking, persistence, data (un)marshaling, UI, etc. But I still choose how all the pieces fit together, like assembling an IKEA piece of furniture.

And, I will never insist too much on it, there's value in learning something the hard way, trying things out, disassembling and reassembling elements to study the internals, is not at all a waste of time. Scientists and technicians, we need to experiment, observe and interpret our results to progress. And the struggle is a great teacher in that: remove the struggle, impoverish the learning.

And resorting to shortcuts is self-inflicted damage too. AI may be wiping out the process of writing code, especially at big scale in large companies. But the ability to understand the "why" behind each line of code is not being superseded: you need it to evaluate and review AI generated code.

Conclusion: Pro-Intentionality, not Anti-AI

The fact that Linus Torvalds recently said that AI is clearly a useful tool, that Linux is not an anti-AI project, and that anyone with issues with that should fork it and go their way, has generated a lot of debate (source here).

Yes, that is true, especially in a project like the Linux kernel where efficiency and scale are the only metrics. For industrial usage, AI makes perfectly sense from an economic point of view.

Nonetheless, the Procyon Project feels like a "workshop" to me (not an industrial "factory") and there is space for human creativity.

Like an artisan pours a little bit of themselves in each one of their works, when side projects are approached like a craft (and not a utility) something similar happens. And the "soul" emerges in little weird details, e.g. some play on words in the theme names.

LLMs aim for the "average" result based on their training data, human work aims for specificities. To me, like to many other devs I guess, there are projects which feel like a "sacred space" for manual craft.

Question

Where do you draw the line between using AI for productivity and letting it take over the "fun" part of your development process?


  1. Or, rather, merging the Lemmy client into the Mastodon / Friendica one, since it is already multi-instance. ↩

  2. The more problems I solve, the more experience I gain, which makes me a better professional in my 9-to-6 job, stay tuned for more in a future article. ↩

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. ↩

Rendering ActivityPub groups (Part 2)

In Part 1 we described what a group is and how its contents are displayed in the so-called "Forum Mode" in Raccoon. As a quick reminder, the main page contains the list of first-level posts re-shared by the group, which roughly correspond to classic forum "threads".

Each thread can be opened to access its detail screen containing the discussion. The thread detail screen has the following layout: the initial post by the OP is displayed at the top and replies appear below.

In this article we will explain how the raccoon proceeds to "dig though the trash" and go deeper and deeper in the discussion thread.

The Family Tree

In a discussion each element has exactly one parent, except for the initial post which has none and is the common ancestor of all others.

In Computer Science, this data structure is called a tree, where the initial post is the root node. This means that conversations are intrinsically hierarchical, where the context of each post is made up of:

  • its parent and ancestors up to the root node;
  • its children and descendants below it.
sample reply tree
Underlying representation of replies as a tree.

Making Threads Look Good

In order to follow a discussion as a reader and writer, it is important to quickly identify two types of relationships:

  • who is answering to whom, i.e. parents and children;
  • posts on the same level because they have the same parent, i.e. siblings.

In order to guide the user in identifying these, two visual elements are introduced:

  • visual offset: posts are progressively indented according to their depth level;
  • comment bar: a colorful bar is displayed laterally with the full height of the post body, so that posts of the same level can be easily identified.1
sample reply list
Visual representation of replies as a list.

Completeness is Key

Reply trees can grow indefinitely (both in breadth and depth) and, it is very important to make sure that:

  • no unneeded extra data is downloaded: more data means more time to load as well as more bandwidth, memory and power consumption;
  • at the same time, if each data fetch is partial, not too many requests are done to the instance, to avoid overloading servers.

As anticipated in part 1, Mastodon APIs offer an endpoint to fetch the context of a post: GET v1/statuses/:id/context. On Mastodon instances, the resulting context is capped to 40 ancestors and 60 descendants with a max depth of 20 for unauthenticated requests, 4096 ancestors and 4096 descendants with unlimited depth for authenticated requests.

API Considerations

Unfortunately, on Friendica this API has historically always been problematic. Friendica is built to aggregate different networks and protocols (ActivityPub, Diaspora, RSS feeds, etc.). Probably due to the more complex DB structure, results of the context API are kwnown to be truncated, with some reply branches entirely missing.

In order to overcome this issue, the only solution was to populate the tree manually. As an example, let's imagine "P0", "P1", etc. to be the post IDs. Let's also assume that response for a GET v1/statuses/P0/context would be the following JSON, where descendants are truncated at the first level:

{
  "ancestors": [],
  "descendants": [
    {
      "id": "P1",
      "replies_count": 3,
      // ...
    },
    {
      "id": "P2",
      "replies_count": 2,
      // ...
    }
  ]
}

We know that the post has no ancestors and has two descendants, one of which with three replies. In order to reconstruct the rest of the tree, we need to: iterate over all descendants having a reply count greater than zero (non-leaves), collect their first-level descendants, then proceed downwards in all subtrees recursively until leaves are reached in every path.

In the process, it is important that we keep track the depth of each post, in order to properly indent them in the layout and for another reason we'll discuss later.

Considering that conversations can grow indefinitely, another condition should be imposed: stop fetching data when a threshold depth is reached, to avoid overloading the server with too many requests and fetching data that the reader may not even be interested to see. The depth property we have been calculating earlier may be useful with this respect.

Since the tree we manipulate is incomplete, we also need to be able to identify those "pruning points" and be able to load the remaining subtree (on-demand) at a later point. Again, the depth property we have been calculating can be used, in conjunction with the reply_count we get from the server.

Step 1: Grow the Tree

private data class Node(
    val entry: TimelineEntryModel,
    var children: List<Node> = listOf(),
)

private suspend fun populateTree(node: Node, depth: Int, max: Int) {
    if (node.entry.replyCount == 0 || depth >= max) {
        return
    }
    val descendants = repository.getContext(node.entry.id)?.descendants.orEmpty()
    val children = descendants.mapNotNull { child ->
        Node(entry = child.copy(depth = node.entry.depth + 1))
    }
    for (child in children) {
        populateTree(node = child, depth = depth + 1, max = max)
    }
    node.children = children
}

Let's break down the code:

  • lines 1-4 define a data structure to represent tree nodes;
  • line 6 contains the function signature, where in the params:
    • depth is the current level in the tree;
    • max is the threshold depth;
  • lines 7-9 contains the base cases, either the current node is a leaf or the max depth is reached;
  • line 10 fetches the post's context from the server and extract the descendants;
  • lines 11-13 retain each node's absolute depth and create the list of children;
  • lines 14-16 recursively calls the function for each child node;
  • line 17 populates the children of the current node.

Imagine we call this function on P0 with max = 2, the result would be:

sample reply list

where in "P5" we know (by its replyCount) that are 2 descendants, but we did not fetch any of them.

Step 2: Linearize the Tree

Building the tree is only half of the puzzle, though. As we've seen earlier posts must be laid out in a list and in a very specific order.

In order to do so, we have another recursive helper:

1
2
3
4
5
6
private fun MutableList<TimelineEntryModel>.linearize(node: Node) {
    add(node.entry)
    for (child in node.children) {
        linearize(child)
    }
}

which traverses the tree depth-first adding each visited node, so that if we consider the post IDs we get ["P0", "P1", "P3", "P4", "P5", "P2", "P6", "P7"] as we wanted.

How do we know, though, that between "P5" and "P2" there is a "pruning point"?

Step 3: Knowing When to Stop

The answer is in the last helper function:

1
2
3
4
5
6
private fun List<TimelineEntryModel>.populateLoadMore() = mapIndexed { idx, entry ->
    val hasMoreReplies = entry.replyCount > 0
    val isNextCommentNotChild =
        idx == lastIndex || this[idx + 1].depth <= entry.depth
    entry.copy(loadMore = hasMoreReplies && isNextCommentNotChild)
}

where we set a loadMore flag in the entries, which is true only if these two conditions hold true at the same time:

  • the entry has at least one reply (replyCount strictly greater than zero);
  • it is the last item of the list or the next item has a depth less than or equal to the current one (i.e. it is a sibling, an uncle or great-uncle).

In those points, in the UI, a "Load more replies" button will be shown which ends up triggering the populateTree function for the subtree rooted in that node.


  1. Depending on the theme, colors also convey the depth: the deeper the level, the darker the color. ↩

Rendering ActivityPub groups (Part 1)

This is Part 1 of a two-article sequence on ActivityPub groups. This post covers ActivityStreams definitions, Mastodon API integration, and the Raccoon rendering logic.

Part 2 will follow with a deep dive into the code behind populating the reply tree in the thread detail screen.

What are Actors?

The AP protocol describes several kinds of Actors, i.e. the entities publishing content, interconnected by relationships and interacting with each other and with content.

Under the hood, for any object to be considered an Actor, it must have the following properties:

  • an inbox, where it receives messages published by others;
  • an outbox, where it publishes its own activities.

In the AS 2.0 lingo, the most common type of actor is a Person, which represents an individual human user. This is what we typically think of as a "user account": the entity that publishes posts, follows others, and interacts with content by liking or reblogging it.

Another very interesting type of actor is a Group. According to the specification, groups represent a formal or informal collective of individuals. In practice, they often act as communities or relays: activities sent to the group are distributed to all its members.

The perfect use case for groups are discussion topics: pages where all first level posts are related to a common topic and each of them can be replied to. This is basically the same structure of online forums, of Reddit's subreddits, Lemmy's communities, and so on…

How this maps to Mastodon

In terms of Mastodon APIs, Groups map to instances of the Account, where the group property is true.1 According to the official docs, this property:

Note

Description:
Indicates that the account represents a Group actor
Type:
Boolean
Version history:
3.1.0 – added

Raccoon Rendering

Forum list

In Raccoon, when opening a user profile and the corresponding account has "group": true the preferred view mode for the user detail screen is the so-called "Forum Mode".

This is very similar to a classic timeline, but with some differences:

  • in the top app bar, the title shows «Topic: [group name]» instead of just the username;
  • posts are retrieved with a GET v1/accounts/:id/statuses request, where the exclude_replies parameter set to true: by doing so, only first-level posts created or boosted by the account are shown;
  • there is a "Plus" button to create a new thread in the forum (first-level post).
forum list screenshot
A screenshot of the user detail screen in "Forum Mode".

Post creation

In the composer, when creating a new thread:

  • an indicator «Post to [group name]» is shown;
  • an @-mention is automatically added at the beginning of the message.2

Instead, when creating nested level replies, the usual reply pattern is applied:

  • an indicator «In reply to [user name]» is shown;
  • a series of @-mentions are automatically added at the beginning.

Thread detail

Conceptually, this screen is very similar to a regular post detail but with some differences:

  • the main post (first-level) is displayed always at the top, in its full layout;
  • replies appear below it and are retrieved with a GET v1/statuses/:id/context request, with a progressive indentation depending on their depth level with a color indicating the level and making it easier to identify same-level answers;
  • Swipe Navigation: you can scroll between threads in the forum with horizontal swipes;
  • there is a "Reply" button to create new second-level replies to the main post.
thread detail screenshot
A screenshot of the thread detail screen with the list of replies.

Stay tuned!

Now that we've covered the "what", it's time to talk about the "how". In the second part of this series, I'll be sharing the code that makes this all possible.

To be honest, this was one of the most complex parts of the project: fetching and organizing the reply tree.

I'll walk you through the hurdles I encountered with the current state of Mastodon APIs and the specific workarounds Raccoon uses to make the conversation flow feel natural without overloading the server instance at the same time.


  1. Support for this kind of entity has been introduced in February 2020 in #12071. ↩

  2. It was a !-mention initially, but it was changed later to increase compatibility (!-mentions are only supported by Friendica) and visibility (with !-mentions, only accounts following the group can see the message). ↩

Is this the end of sideloading on Android?

If you've been following the news from Google I/O 2026, you probably felt a bit of a chill down your spine when the "Android Developer Verification" initiative was announced. For those who were too busy debugging like me, the news is: starting September 30, 2026, Android will change how it handles apps installed outside Play Store.

TL;DR: Every single Android app, regardless of how it's distributed, must be linked to a verified developer identity. If an app is not registered by a verified dev, users will have to go through a new "Advanced Sideloading Flow": this includes a mandatory 24 hour "cooling-off" period, biometric checks, and a warning that you might be getting scammed (see the official Android Developers Blog for more information).

The "Safety" Argument

Google's reasoning for this change is, as expected, centered on security and fraud prevention. The goal is to eliminate the "shadow economy" of malicious APKs used in social engineering scams. Requiring a traceable identity for every piece of code that runs on a certified device, makes it significantly harder for scammers to operate anonymously.

It's basically a KYC approach but targeting developers instead of end users: "Know Your Developers". If a malicious app is found, there's a real person or entity to hold accountable or at least, a verified ID that can be revoked across the entire ecosystem instantly.

Divided Reactions

Reactions from the community was as polarized, as one may expect.

The "Pro" Camp:

Security researchers and enterprise IT managers have largely welcomed the move. For them, the "Wild West" of Android sideloading has always been a liability. Reducing the risk of a "non-techy" relative accidentally installing a banking Trojan via a WhatsApp link is seen as a huge win for the average consumer.

The "Against" Camp:

On the other side, privacy advocates are rightly concerned about the requirement for government-issued ID just to share a hobby project. The F-Droid community and the "Right to Repair" crowd see this as a slow-motion execution of the very thing that made Android great: its openness.

E.g. The 24-hour wait period for unverified apps may be seen as "friction by design"; intended to frustrate users into staying within the "walled garden" of the Play Store.

The Raccoon's Two Cents

As someone who spends a lot of time in the "trash" (aka open-source side projects), I find myself stuck somewhere in the middle.

On one hand, publishing on F-Droid has always been a painful experience for developers. Between the strict requirements for reproducible builds and the manual controls involved, every update feels like rolling a die. You never really know when (or even if) your latest fix will actually reach your users. In a world where F-Droid already struggles with distribution lag, adding a 24-hour OS-level delay on top of it feels like a kick while you're down.

On the other hand, wearing my professional developer hat, I have to admit that quality and requirement verification is more important than ever. The barrier to creating mobile apps has significantly lowered in the AI era which means the market is flooded with low-effort, unsafe, or potentially deceptive software. Some level of accountability is necessary, and Google had already started going down that path,1 if (like me) you choose to distribute your apps through the Play Store, you have to follow the rules, no exception for anyone.

Closing thoughts

Is this the end of sideloading? Not quite (for now). But the days of the "one-tap APK install" are clearly numbered. We're moving toward a future where freedom means more responsibility and awareness of the consequences of your actions.

I'm curious to see how the Fediverse reacts once the rollout hits the pilot countries in September. Until then, I'll be over here, double-checking my developer identity and hoping my reproducible builds actually… well, reproduce.

Question

What do you think? Is the security tradeoff worth the loss of friction-less freedom?

By the way, as of now, the version of Raccoon for Mastodon / Friendica on F-Droid is 0.4.2, despite having released 1.0.0 twenty days ago. After the hard work of making a new release available, it is frustrating to see that users are prevented from installing it (and operating system restrictions are not enforced yet)!


  1. E.g. In November 2023 they introduced a policy that requires developers with newly created personal accounts to conduct closed testing with at least 20 testers for 14 days before applying for production access. ↩

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. ↩

The Raccoon's Dilemma: Balancing Features, Maintenance, and Sanity

The idea of writing about the balance between features and maintenance in our apps has been lingering in my mind for over a year. But it never felt like the right moment, so I kept putting it off… ironically, somehow like the maintenance tasks I'm writing about.

This procrastination alone says a lot about my way of managing open-source side projects. It's a delicate dance between personal life and professional obligations. Days only have 24 hours, weeks only have 7 days (trust me, I've checked)!

Between family, a full-time job, and trying to be a functional adult in these chaotic times, maintaining apps can feel like a secondary full-time job, just without any paycheck.

One might wonder: Why on earth do I do this? It's a fair question. In today's article, I want to pull back the curtain on why Raccoon exists and why our development roadmap looks the way it does.

Longing for the Wild

I've worked as a developer for more years than I'd like to admit (let's say my early code belongs in a museum, or perhaps a dumpster). Throughout that time, I often struggled to find room to experiment. As a junior, every choice was made for me. As I moved into mid and senior roles, I was often tethered to legacy systems or constrained by "steering committees" which is often corporate-speak for «we don't like change».

I don't blame them: companies avoid risk because a bad tech choice can haunt a team for years. So, I was usually left playing in the safe corners of projects.

Even while bouncing between backend, web, iOS, and Android, something was missing. I missed the thrill of building from the ground up. I wanted the freedom to choose a foundation, evaluate it in the real world, and pull a complete U-turn on failure without causing a financial crisis for my employer.

The Perfect Storm

In 2023 the stars aligned. Kotlin Multiplatform1 transitioned from Beta to Stable(and Compose Multiplatform was in Alpha for iOS and stable elsewhere).

Meanwhile, the "Reddit API Apocalypse" killed off third-party clients, driving a massive migration toward the "threadiverse" (Lemmy, Kbin, Sublinks, Friendica, etc.).

On a personal note, in early 2023 I had finally switched to a job that actually respected my evenings and weekends, no more unpaid overtime! 🎉 🦝 🎉

These factors converged into a single goal: build a Lemmy client for Android and iOS using KMP. A year later, the Friendica client followed.

This was my playground. I could try libraries, break things, and discard what didn't work for my use cases (like Koin or Ktorfit).

So, finally, I had:

  • total tech freedom: I was the lead, the junior, and the steering committee.
  • continuous learning: hands-on experience with the bleeding edge.
  • purpose: contributing something useful to an ecosystem I actually believed in.

The "Why" Behind the Trash

Even without a paycheck, the ROI on these projects is massive. It's professional growth and psychological satisfaction rolled into one. It's a process of hypothesis and experimentation, not unlike academic research, but with more raccoons and fewer lab coats.

This also explains why instead of always going on and pile new features one on top of the other, sometimes the development process seems to go in circles: e.g. switching from a network library to another, rewrite the app navigation system, migrate localization from a framework to another, etc.

But why talk about this right now?

Recently, I hit a milestone: version 1.0.0 of the Mastodon/Friendica client. A local admin wrote an announcement about it (in Italian). Then they ran it through machine translation and posted an English version.

You know, the Fediverse is federated, so this propagated to Lemmy, where naturally critics arrived:

Quote

Why are people upvoting this? If OP used AI to 'make' this post, obviously the app is gonna be slop too.

The "proofs"? The word "finally" appeared in the middle of a list. Apparently, to some, using transition words is a sign of a robotic takeover rather than, you know, being relieved that a some longed-for feature is available to end users.

The Human (and Raccoon) Element

This brings us back to sustainability. When we maintain open-source projects, we aren't just pushing code; we're managing a community and navigating the weirdness of the Internet.

The "AI slop" accusation stung, but it also highlighted the irony: I do this work specifically to keep the human element alive in tech. I build these apps so we have independent, non-corporate ways to communicate and I do so because I love software development and learning new technologies.

So, to the "lemming" who thought I was a bot: I'm not. I'm just a developer who likes KMP, hates unnecessary API fees, and occasionally enjoys a piece of "trash" code that eventually may turn into treasure.


  1. At that time it was still called Kotlin Multiplatform Mobile (KMM) for those who remember. ↩